<?php

declare(strict_types=1);

namespace Drupal\flowdrop_orchestration\DTO\Snapshot;

/**
 * Represents the state of an individual workflow node at a point in time.
 *
 * NodeSnapshot captures everything needed to understand a node's execution
 * state, including its output data, timing information, and whether the
 * state was injected externally (e.g., during orchestrator handover).
 *
 * This DTO is immutable - all modifications return new instances.
 *
 * Example usage:
 * @code
 * // Create a pending node
 * $node = NodeSnapshot::pending("node_chat_model_1");
 *
 * // Create a completed node with output
 * $node = NodeSnapshot::completed(
 *   nodeId: "node_text_input_1",
 *   output: ["text" => "Hello world"],
 *   executionOrder: 1,
 * );
 *
 * // Create an injected node (from handover)
 * $node = NodeSnapshot::injected(
 *   nodeId: "node_trigger_1",
 *   output: ["entity" => $entityData],
 * );
 * @endcode
 *
 * @internal This class is not part of the public API and may change without notice.
 *
 * @see \Drupal\flowdrop_orchestration\DTO\Snapshot\WorkflowSnapshot
 */
final class NodeSnapshot implements \JsonSerializable {

  /**
   * Node status constants.
   *
   * Status lifecycle:
   * - idle: Job created but dependencies not yet met.
   * - pending: Job ready to run (dependencies met).
   * - running: Job currently executing.
   * - completed: Job finished successfully.
   * - failed: Job execution failed.
   * - skipped: Job not executed (workflow completed/failed before it ran).
   * - interrupted: Node paused for human-in-the-loop input.
   */
  public const STATUS_IDLE = "idle";
  public const STATUS_PENDING = "pending";
  public const STATUS_RUNNING = "running";
  public const STATUS_COMPLETED = "completed";
  public const STATUS_FAILED = "failed";
  public const STATUS_SKIPPED = "skipped";
  public const STATUS_INTERRUPTED = "interrupted";

  /**
   * Valid status values.
   *
   * @var array<string>
   */
  private const VALID_STATUSES = [
    self::STATUS_IDLE,
    self::STATUS_PENDING,
    self::STATUS_RUNNING,
    self::STATUS_COMPLETED,
    self::STATUS_FAILED,
    self::STATUS_SKIPPED,
    self::STATUS_INTERRUPTED,
  ];

  /**
   * Constructs a new NodeSnapshot.
   *
   * @param string $nodeId
   *   The unique node identifier within the workflow.
   * @param string $status
   *   The node execution status (pending|running|completed|failed|skipped).
   * @param array<string, mixed>|null $output
   *   The node output data when completed, NULL otherwise.
   * @param string|null $error
   *   Error message if the node failed, NULL otherwise.
   * @param bool $injected
   *   Whether this state was injected externally (e.g., during handover).
   * @param int $executionOrder
   *   The order in which this node was executed (for conflict resolution).
   *   Higher values indicate later execution. 0 means not yet executed.
   * @param int|null $startedAt
   *   Unix timestamp when execution started, NULL if not started.
   * @param int|null $completedAt
   *   Unix timestamp when execution completed, NULL if not completed.
   * @param array<string, mixed> $metadata
   *   Additional node-specific metadata.
   */
  public function __construct(
    public readonly string $nodeId,
    public readonly string $status,
    public readonly ?array $output = NULL,
    public readonly ?string $error = NULL,
    public readonly bool $injected = FALSE,
    public readonly int $executionOrder = 0,
    public readonly ?int $startedAt = NULL,
    public readonly ?int $completedAt = NULL,
    public readonly array $metadata = [],
  ) {
    // Validate status.
    if (!in_array($status, self::VALID_STATUSES, TRUE)) {
      throw new \InvalidArgumentException(
        "Invalid node status '{$status}'. Must be one of: " . implode(", ", self::VALID_STATUSES)
      );
    }
  }

  /**
   * Creates a pending node snapshot.
   *
   * @param string $nodeId
   *   The node ID.
   * @param array<string, mixed> $metadata
   *   Optional metadata.
   *
   * @return self
   *   A new pending NodeSnapshot.
   */
  public static function pending(string $nodeId, array $metadata = []): self {
    return new self(
      nodeId: $nodeId,
      status: self::STATUS_PENDING,
      metadata: $metadata,
    );
  }

  /**
   * Creates a running node snapshot.
   *
   * @param string $nodeId
   *   The node ID.
   * @param int $executionOrder
   *   The execution order.
   * @param array<string, mixed> $metadata
   *   Optional metadata.
   *
   * @return self
   *   A new running NodeSnapshot.
   */
  public static function running(
    string $nodeId,
    int $executionOrder = 0,
    array $metadata = [],
  ): self {
    return new self(
      nodeId: $nodeId,
      status: self::STATUS_RUNNING,
      executionOrder: $executionOrder,
      startedAt: time(),
      metadata: $metadata,
    );
  }

  /**
   * Creates a completed node snapshot.
   *
   * @param string $nodeId
   *   The node ID.
   * @param array<string, mixed> $output
   *   The node output data.
   * @param int $executionOrder
   *   The execution order.
   * @param int|null $startedAt
   *   When execution started (defaults to now if not provided).
   * @param array<string, mixed> $metadata
   *   Optional metadata.
   *
   * @return self
   *   A new completed NodeSnapshot.
   */
  public static function completed(
    string $nodeId,
    array $output,
    int $executionOrder = 0,
    ?int $startedAt = NULL,
    array $metadata = [],
  ): self {
    $now = time();
    return new self(
      nodeId: $nodeId,
      status: self::STATUS_COMPLETED,
      output: $output,
      executionOrder: $executionOrder,
      startedAt: $startedAt ?? $now,
      completedAt: $now,
      metadata: $metadata,
    );
  }

  /**
   * Creates an injected (pre-completed) node snapshot.
   *
   * Used during orchestrator handover when a node's output is known
   * but the node wasn't actually executed in this context.
   *
   * @param string $nodeId
   *   The node ID.
   * @param array<string, mixed> $output
   *   The injected output data.
   * @param int $executionOrder
   *   The execution order from the source context.
   * @param array<string, mixed> $metadata
   *   Optional metadata (e.g., source_orchestrator).
   *
   * @return self
   *   A new injected NodeSnapshot.
   */
  public static function injected(
    string $nodeId,
    array $output,
    int $executionOrder = 0,
    array $metadata = [],
  ): self {
    return new self(
      nodeId: $nodeId,
      status: self::STATUS_COMPLETED,
      output: $output,
      injected: TRUE,
      executionOrder: $executionOrder,
      completedAt: time(),
      metadata: array_merge($metadata, ["injected_at" => time()]),
    );
  }

  /**
   * Creates a failed node snapshot.
   *
   * @param string $nodeId
   *   The node ID.
   * @param string $error
   *   The error message.
   * @param int $executionOrder
   *   The execution order.
   * @param int|null $startedAt
   *   When execution started.
   * @param array<string, mixed> $metadata
   *   Optional metadata.
   *
   * @return self
   *   A new failed NodeSnapshot.
   */
  public static function failed(
    string $nodeId,
    string $error,
    int $executionOrder = 0,
    ?int $startedAt = NULL,
    array $metadata = [],
  ): self {
    $now = time();
    return new self(
      nodeId: $nodeId,
      status: self::STATUS_FAILED,
      error: $error,
      executionOrder: $executionOrder,
      startedAt: $startedAt ?? $now,
      completedAt: $now,
      metadata: $metadata,
    );
  }

  /**
   * Creates a skipped node snapshot.
   *
   * @param string $nodeId
   *   The node ID.
   * @param string $reason
   *   The reason for skipping (stored in metadata).
   * @param array<string, mixed> $metadata
   *   Optional additional metadata.
   *
   * @return self
   *   A new skipped NodeSnapshot.
   */
  public static function skipped(
    string $nodeId,
    string $reason = "",
    array $metadata = [],
  ): self {
    return new self(
      nodeId: $nodeId,
      status: self::STATUS_SKIPPED,
      metadata: array_merge($metadata, ["skip_reason" => $reason]),
    );
  }

  /**
   * Creates a new snapshot with updated status.
   *
   * @param string $status
   *   The new status.
   *
   * @return self
   *   A new NodeSnapshot with updated status.
   */
  public function withStatus(string $status): self {
    return new self(
      nodeId: $this->nodeId,
      status: $status,
      output: $this->output,
      error: $this->error,
      injected: $this->injected,
      executionOrder: $this->executionOrder,
      startedAt: $this->startedAt,
      completedAt: $status === self::STATUS_COMPLETED ? time() : $this->completedAt,
      metadata: $this->metadata,
    );
  }

  /**
   * Creates a new snapshot with output data.
   *
   * @param array<string, mixed> $output
   *   The output data.
   *
   * @return self
   *   A new NodeSnapshot with output.
   */
  public function withOutput(array $output): self {
    return new self(
      nodeId: $this->nodeId,
      status: self::STATUS_COMPLETED,
      output: $output,
      error: $this->error,
      injected: $this->injected,
      executionOrder: $this->executionOrder,
      startedAt: $this->startedAt,
      completedAt: time(),
      metadata: $this->metadata,
    );
  }

  /**
   * Creates a new snapshot with error.
   *
   * @param string $error
   *   The error message.
   *
   * @return self
   *   A new NodeSnapshot with error.
   */
  public function withError(string $error): self {
    return new self(
      nodeId: $this->nodeId,
      status: self::STATUS_FAILED,
      output: $this->output,
      error: $error,
      injected: $this->injected,
      executionOrder: $this->executionOrder,
      startedAt: $this->startedAt,
      completedAt: time(),
      metadata: $this->metadata,
    );
  }

  /**
   * Creates a new snapshot with additional metadata.
   *
   * @param array<string, mixed> $metadata
   *   Metadata to merge.
   *
   * @return self
   *   A new NodeSnapshot with merged metadata.
   */
  public function withMetadata(array $metadata): self {
    return new self(
      nodeId: $this->nodeId,
      status: $this->status,
      output: $this->output,
      error: $this->error,
      injected: $this->injected,
      executionOrder: $this->executionOrder,
      startedAt: $this->startedAt,
      completedAt: $this->completedAt,
      metadata: array_merge($this->metadata, $metadata),
    );
  }

  /**
   * Checks if the node is in a terminal state.
   *
   * @return bool
   *   TRUE if completed, failed, or skipped.
   */
  public function isTerminal(): bool {
    return in_array($this->status, [
      self::STATUS_COMPLETED,
      self::STATUS_FAILED,
      self::STATUS_SKIPPED,
    ], TRUE);
  }

  /**
   * Checks if the node completed successfully.
   *
   * @return bool
   *   TRUE if status is completed.
   */
  public function isCompleted(): bool {
    return $this->status === self::STATUS_COMPLETED;
  }

  /**
   * Checks if the node failed.
   *
   * @return bool
   *   TRUE if status is failed.
   */
  public function isFailed(): bool {
    return $this->status === self::STATUS_FAILED;
  }

  /**
   * Checks if the node is pending.
   *
   * @return bool
   *   TRUE if status is pending.
   */
  public function isPending(): bool {
    return $this->status === self::STATUS_PENDING;
  }

  /**
   * Checks if the node is currently running.
   *
   * @return bool
   *   TRUE if status is running.
   */
  public function isRunning(): bool {
    return $this->status === self::STATUS_RUNNING;
  }

  /**
   * Gets the execution duration in seconds.
   *
   * @return float|null
   *   Duration in seconds, or NULL if not applicable.
   */
  public function getDuration(): ?float {
    if ($this->startedAt === NULL || $this->completedAt === NULL) {
      return NULL;
    }
    return (float) ($this->completedAt - $this->startedAt);
  }

  /**
   * Creates a NodeSnapshot from an array.
   *
   * @param array<string, mixed> $data
   *   The snapshot data.
   *
   * @return self
   *   A new NodeSnapshot instance.
   */
  public static function fromArray(array $data): self {
    return new self(
      nodeId: (string) ($data["nodeId"] ?? $data["node_id"] ?? ""),
      status: (string) ($data["status"] ?? self::STATUS_PENDING),
      output: isset($data["output"]) && is_array($data["output"]) ? $data["output"] : NULL,
      error: isset($data["error"]) ? (string) $data["error"] : NULL,
      injected: (bool) ($data["injected"] ?? FALSE),
      executionOrder: (int) ($data["executionOrder"] ?? $data["execution_order"] ?? 0),
      startedAt: isset($data["startedAt"]) || isset($data["started_at"])
        ? (int) ($data["startedAt"] ?? $data["started_at"])
        : NULL,
      completedAt: isset($data["completedAt"]) || isset($data["completed_at"])
        ? (int) ($data["completedAt"] ?? $data["completed_at"])
        : NULL,
      metadata: $data["metadata"] ?? [],
    );
  }

  /**
   * Converts the snapshot to an array.
   *
   * @return array<string, mixed>
   *   The snapshot as an associative array.
   */
  public function toArray(): array {
    return [
      "nodeId" => $this->nodeId,
      "status" => $this->status,
      "output" => $this->output,
      "error" => $this->error,
      "injected" => $this->injected,
      "executionOrder" => $this->executionOrder,
      "startedAt" => $this->startedAt,
      "completedAt" => $this->completedAt,
      "metadata" => $this->metadata,
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function jsonSerialize(): mixed {
    return $this->toArray();
  }

}
