<?php

declare(strict_types=1);

namespace Drupal\flowdrop_runtime\Service\Orchestrator;

use Drupal\flowdrop_job\FlowDropJobInterface;
use Drupal\flowdrop_pipeline\Entity\FlowDropPipelineInterface;
use Drupal\flowdrop_pipeline\Service\JobGenerationService;
use Drupal\flowdrop_runtime\DTO\Runtime\NodeExecutionContext;
use Drupal\flowdrop_orchestration\DTO\Snapshot\NodeSnapshot;
use Drupal\flowdrop_orchestration\DTO\Snapshot\WorkflowSnapshot;
use Drupal\flowdrop_orchestration\Exception\InterruptExceptionInterface;
use Drupal\flowdrop_runtime\Service\RealTime\RealTimeManagerInterface;
use Drupal\flowdrop_runtime\Service\Runtime\NodeRuntimeServiceInterface;
use Drupal\flowdrop_runtime\Service\Storage\JobStorageInterface;
use Drupal\flowdrop_runtime\Service\Storage\PipelineStorageInterface;
use Drupal\flowdrop_runtime\Service\WorkflowState\WorkflowStateManagerInterface;
use Drupal\flowdrop_runtime\Service\Orchestrator\Trait\JobSkippingTrait;
use Drupal\flowdrop_runtime\Service\Orchestrator\Trait\PipelineCreationTrait;
use Drupal\flowdrop_runtime\Service\Orchestrator\Trait\PortNameExtractionTrait;
use Drupal\flowdrop_orchestration\Service\Orchestrator\OrchestratorInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\GenericEvent;

/**
 * Abstract base class for all orchestrators.
 *
 * This class provides common functionality shared across all orchestrator
 * implementations, including:
 * - Pipeline and job storage abstraction
 * - Common execution methods (executeJob, buildRuntimeInputData)
 * - Interrupt handling
 * - Snapshot generation
 * - Event dispatching.
 *
 * All orchestrators should extend this class and implement:
 * - getType(): Returns the orchestrator type identifier
 * - orchestrate(): Main orchestration logic
 * - getCapabilities(): Returns capability flags
 *
 * @api
 */
abstract class AbstractOrchestratorBase implements OrchestratorInterface {

  use InterruptHandlingTrait;
  use JobSkippingTrait;
  use PortNameExtractionTrait;
  use PipelineCreationTrait;

  /**
   * Constructs a new AbstractOrchestratorBase.
   *
   * @param \Drupal\flowdrop_runtime\Service\Storage\PipelineStorageInterface $pipelineStorage
   *   The pipeline storage service.
   * @param \Drupal\flowdrop_runtime\Service\Storage\JobStorageInterface $jobStorage
   *   The job storage service.
   * @param \Drupal\flowdrop_runtime\Service\Runtime\NodeRuntimeServiceInterface $nodeRuntime
   *   The node runtime service.
   * @param \Drupal\flowdrop_pipeline\Service\JobGenerationService $jobGenerationService
   *   The job generation service.
   * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $eventDispatcher
   *   The event dispatcher.
   * @param \Psr\Log\LoggerInterface $logger
   *   The logger service.
   * @param \Drupal\flowdrop_runtime\Service\WorkflowState\WorkflowStateManagerInterface|null $workflowStateManager
   *   The workflow state manager (optional).
   * @param \Drupal\flowdrop_runtime\Service\RealTime\RealTimeManagerInterface|null $realTimeManager
   *   The real-time manager (optional).
   */
  public function __construct(
    protected readonly PipelineStorageInterface $pipelineStorage,
    protected readonly JobStorageInterface $jobStorage,
    protected readonly NodeRuntimeServiceInterface $nodeRuntime,
    protected readonly JobGenerationService $jobGenerationService,
    protected readonly EventDispatcherInterface $eventDispatcher,
    protected readonly LoggerInterface $logger,
    protected readonly ?WorkflowStateManagerInterface $workflowStateManager = NULL,
    protected readonly ?RealTimeManagerInterface $realTimeManager = NULL,
  ) {
  }

  /**
   * {@inheritdoc}
   */
  public function supportsWorkflow(array $workflow): bool {
    return TRUE;
  }

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

  /**
   * Reloads a pipeline to get fresh data from storage.
   *
   * @param \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineInterface $pipeline
   *   The pipeline to reload.
   *
   * @return \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineInterface
   *   The reloaded pipeline.
   *
   * @throws \Drupal\flowdrop_runtime\Exception\OrchestrationException
   *   If the pipeline cannot be reloaded.
   */
  protected function reloadPipeline(FlowDropPipelineInterface $pipeline): FlowDropPipelineInterface {
    $pipelineId = $pipeline->id();
    if ($pipelineId === NULL) {
      return $pipeline;
    }

    // Reset caches to ensure fresh data.
    $this->pipelineStorage->resetCache([(string) $pipelineId]);
    $this->jobStorage->resetCache();

    $reloaded = $this->pipelineStorage->load($pipelineId);
    if ($reloaded === NULL) {
      throw new \RuntimeException("Failed to reload pipeline: {$pipelineId}");
    }

    return $reloaded;
  }

  /**
   * 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.
   *
   * @return array
   *   The job output data.
   *
   * @throws \Drupal\flowdrop_orchestration\Exception\InterruptExceptionInterface
   *   If the job is interrupted for human input.
   * @throws \Exception
   *   If job execution fails.
   */
  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();
      $this->jobStorage->save($job);

      // Get config from metadata.
      $metadata = $job->getMetadata();
      $configData = $metadata["config"] ?? [];

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

      // Inject interrupt ID for resume support.
      $interruptId = $job->getMetadataValue("interrupt_id", NULL);
      if ($interruptId !== NULL) {
        $runtimeInputData["__interrupt_id__"] = $interruptId;
      }

      // Update job's input data.
      if (!empty($runtimeInputData)) {
        $job->setInputData($runtimeInputData);
        $this->jobStorage->save($job);
      }

      // Build execution context.
      $context = $this->buildNodeExecutionContext($job, $pipeline);
      $nodeTypeId = $job->getMetadataValue("node_type_id", "default");

      // Execute the node.
      $result = $this->nodeRuntime->executeNode(
        $executionId,
        $job->getNodeId(),
        $nodeTypeId,
        $runtimeInputData,
        $configData,
        $context
      );

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

      // Update execution metadata.
      $this->updateJobExecutionMetadata($job, $pipeline, $result->getExecutionTime());
      $this->jobStorage->save($job);

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

      return $outputData;
    }
    catch (InterruptExceptionInterface $e) {
      $job->markAsInterrupted($e->getInterruptId(), $e->getMessage());
      $this->jobStorage->save($job);

      $this->logger->info("Job @job_id interrupted. Interrupt ID: @interrupt_id", [
        "@job_id" => $job->id(),
        "@interrupt_id" => $e->getInterruptId(),
        "@execution_id" => $executionId,
      ]);

      throw $e;
    }
    catch (\Exception $e) {
      $job->markAsFailed($e->getMessage());
      $this->jobStorage->save($job);

      $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 upstream completed jobs.
   *
   * @param \Drupal\flowdrop_job\FlowDropJobInterface $job
   *   The job to build input data for.
   * @param \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineInterface $pipeline
   *   The pipeline.
   *
   * @return array
   *   Runtime input data mapped by port names.
   */
  protected function buildRuntimeInputData(
    FlowDropJobInterface $job,
    FlowDropPipelineInterface $pipeline,
  ): array {
    $runtimeData = [];
    $metadata = $job->getMetadata();
    $incomingEdges = $metadata["incoming_edges"] ?? [];

    // Build map of completed jobs.
    $completedJobs = $pipeline->getJobsByStatus("completed");
    $completedJobsMap = [];
    $executionOrderMap = [];
    foreach ($completedJobs as $completedJob) {
      $completedJobsMap[$completedJob->getNodeId()] = $completedJob;
      $jobMetadata = $completedJob->getMetadata();
      $order = $jobMetadata["execution_order"] ?? 0;
      if ($order > 0) {
        $executionOrderMap[$completedJob->getNodeId()] = $order;
      }
    }

    // Sort edges by execution order (latest first).
    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 ($orderA > 0 && $orderB > 0) {
        return $orderB <=> $orderA;
      }
      if ($orderA > 0) {
        return -1;
      }
      if ($orderB > 0) {
        return 1;
      }
      return strcmp($sourceA, $sourceB);
    });

    // Track port sources for conflict detection.
    $portSources = [];

    foreach ($incomingEdges as $edge) {
      if ($edge["is_trigger"] ?? FALSE) {
        continue;
      }

      $sourceNodeId = $edge["source"];
      if (!isset($completedJobsMap[$sourceNodeId])) {
        continue;
      }

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

      $sourcePortName = $this->extractPortName($edge["source_handle"] ?? "", "output");
      $targetPortName = $this->extractPortName($edge["target_handle"] ?? "", "input");

      if ($sourcePortName !== NULL && $targetPortName !== NULL && isset($sourceOutput[$sourcePortName])) {
        if (isset($portSources[$targetPortName])) {
          $this->logger->warning("Multiple sources target port '@port' on job '@job'", [
            "@port" => $targetPortName,
            "@job" => $job->id(),
          ]);
        }
        $runtimeData[$targetPortName] = $sourceOutput[$sourcePortName];
        $portSources[$targetPortName] = $sourceNodeId;
      }
    }

    return $runtimeData;
  }

  /**
   * Builds node execution context.
   *
   * @param \Drupal\flowdrop_job\FlowDropJobInterface $job
   *   The job being executed.
   * @param \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineInterface $pipeline
   *   The pipeline.
   *
   * @return \Drupal\flowdrop_runtime\DTO\Runtime\NodeExecutionContext
   *   The execution context.
   */
  protected function buildNodeExecutionContext(
    FlowDropJobInterface $job,
    FlowDropPipelineInterface $pipeline,
  ): NodeExecutionContext {
    $workflow = $pipeline->getWorkflow();
    $workflowId = $workflow !== NULL ? ($workflow->id() ?? "") : "";
    $pipelineId = $pipeline->id() ?? "";

    $metadata = [
      "job_id" => $job->id(),
      "node_id" => $job->getNodeId(),
    ];

    // Include session_id for interrupt handling.
    $pipelineContext = $pipeline->getExecutionContext();
    if (isset($pipelineContext["session_id"])) {
      $metadata["session_id"] = $pipelineContext["session_id"];
    }
    elseif (isset($pipelineContext["playground"]["session_id"])) {
      $metadata["session_id"] = $pipelineContext["playground"]["session_id"];
    }

    return new NodeExecutionContext(
      workflowId: $workflowId,
      pipelineId: $pipelineId,
      initialData: $pipeline->getInputData(),
      metadata: $metadata
    );
  }

  /**
   * Updates job execution metadata after successful execution.
   *
   * @param \Drupal\flowdrop_job\FlowDropJobInterface $job
   *   The job.
   * @param \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineInterface $pipeline
   *   The pipeline.
   * @param float $executionTime
   *   The execution time in seconds.
   */
  protected function updateJobExecutionMetadata(
    FlowDropJobInterface $job,
    FlowDropPipelineInterface $pipeline,
    float $executionTime,
  ): void {
    // Calculate execution order.
    $allJobs = $pipeline->getJobs();
    $maxOrder = 0;
    foreach ($allJobs as $otherJob) {
      $otherMetadata = $otherJob->getMetadata();
      $maxOrder = max($maxOrder, $otherMetadata["execution_order"] ?? 0);
    }

    $jobMetadata = $job->getMetadata();
    $jobMetadata["execution_order"] = $maxOrder + 1;
    $jobMetadata["execution_time_us"] = (int) round($executionTime * 1000000);
    $job->setMetadata($jobMetadata);
  }

  /**
   * Generates a WorkflowSnapshot from pipeline state.
   *
   * @param \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineInterface $pipeline
   *   The pipeline.
   * @param string $executionId
   *   The execution ID.
   * @param string $status
   *   The snapshot status.
   *
   * @return \Drupal\flowdrop_orchestration\DTO\Snapshot\WorkflowSnapshot|null
   *   The snapshot, or NULL if state manager is unavailable.
   */
  protected function generateSnapshotFromPipeline(
    FlowDropPipelineInterface $pipeline,
    string $executionId,
    string $status,
  ): ?WorkflowSnapshot {
    if ($this->workflowStateManager === NULL) {
      return NULL;
    }

    try {
      $workflow = $pipeline->getWorkflow();
      $workflowId = $workflow !== NULL ? ($workflow->id() ?? "unknown") : "unknown";
      $workflowVersion = "";

      if ($workflow !== NULL) {
        $workflowVersion = $this->workflowStateManager->computeWorkflowVersion([
          "nodes" => $workflow->getNodes(),
          "edges" => $workflow->getEdges(),
        ]);
      }

      $nodeStates = [];
      foreach ($pipeline->getJobs() as $job) {
        $nodeStates[$job->getNodeId()] = $this->createNodeSnapshotFromJob($job);
      }

      return new WorkflowSnapshot(
        workflowId: $workflowId,
        workflowVersion: $workflowVersion,
        executionId: $executionId,
        status: $status,
        nodeStates: $nodeStates,
        initialInput: $pipeline->getInputData(),
        metadata: [
          "orchestrator_type" => $this->getType(),
          "pipeline_id" => $pipeline->id(),
        ],
        createdAt: time(),
        updatedAt: time(),
      );
    }
    catch (\Exception $e) {
      $this->logger->error("Failed to generate snapshot: @error", [
        "@error" => $e->getMessage(),
      ]);
      return NULL;
    }
  }

  /**
   * Creates a NodeSnapshot from a job.
   *
   * @param \Drupal\flowdrop_job\FlowDropJobInterface $job
   *   The job.
   *
   * @return \Drupal\flowdrop_orchestration\DTO\Snapshot\NodeSnapshot
   *   The node snapshot.
   */
  protected function createNodeSnapshotFromJob(FlowDropJobInterface $job): NodeSnapshot {
    $jobStatus = $job->getStatus();
    $jobMetadata = $job->getMetadata();

    $nodeStatus = match ($jobStatus) {
      "idle" => NodeSnapshot::STATUS_IDLE,
      "pending" => NodeSnapshot::STATUS_PENDING,
      "running" => NodeSnapshot::STATUS_RUNNING,
      "completed" => NodeSnapshot::STATUS_COMPLETED,
      "failed" => NodeSnapshot::STATUS_FAILED,
      "skipped", "cancelled" => NodeSnapshot::STATUS_SKIPPED,
      "interrupted" => NodeSnapshot::STATUS_INTERRUPTED,
      default => NodeSnapshot::STATUS_IDLE,
    };

    return new NodeSnapshot(
      nodeId: $job->getNodeId(),
      status: $nodeStatus,
      output: $jobStatus === "completed" ? $job->getOutputData() : NULL,
      error: $jobStatus === "failed" ? $job->getErrorMessage() : NULL,
      injected: (bool) ($jobMetadata["injected"] ?? FALSE),
      executionOrder: (int) ($jobMetadata["execution_order"] ?? 0),
      metadata: ["job_id" => $job->id()],
    );
  }

  /**
   * Dispatches an event.
   *
   * @param string $eventName
   *   The event name.
   * @param mixed $subject
   *   The event subject.
   * @param array $context
   *   Additional context.
   */
  protected function dispatchEvent(string $eventName, mixed $subject, array $context = []): void {
    $this->eventDispatcher->dispatch(
      new GenericEvent($subject, $context),
      $eventName
    );
  }

  // =========================================================================
  // Trait accessor implementations
  // =========================================================================

  /**
   * {@inheritdoc}
   */
  protected function getPipelineStorage(): PipelineStorageInterface {
    return $this->pipelineStorage;
  }

  /**
   * {@inheritdoc}
   */
  protected function getJobSkippingLogger(): LoggerInterface {
    return $this->logger;
  }

  /**
   * {@inheritdoc}
   */
  protected function getJobSkippingStorage(): JobStorageInterface {
    return $this->jobStorage;
  }

  /**
   * {@inheritdoc}
   */
  protected function getPipelineCreationLogger(): LoggerInterface {
    return $this->logger;
  }

  /**
   * {@inheritdoc}
   */
  protected function getPipelineCreationEventDispatcher(): EventDispatcherInterface {
    return $this->eventDispatcher;
  }

  /**
   * {@inheritdoc}
   */
  protected function getInterruptRealTimeManager(): RealTimeManagerInterface {
    if ($this->realTimeManager === NULL) {
      throw new \RuntimeException("RealTimeManager required but not injected.");
    }
    return $this->realTimeManager;
  }

  /**
   * {@inheritdoc}
   */
  protected function getInterruptEventDispatcher(): EventDispatcherInterface {
    return $this->eventDispatcher;
  }

  /**
   * {@inheritdoc}
   */
  protected function getInterruptLogger(): LoggerInterface {
    return $this->logger;
  }

}
