<?php

declare(strict_types=1);

namespace Drupal\flowdrop_stategraph\Routing;

use Drupal\flowdrop_pipeline\Entity\FlowDropPipelineRuntimeInterface;
use Drupal\flowdrop_stategraph\DTO\GraphState;
use Psr\Log\LoggerInterface;

/**
 * Evaluates conditional edges to determine routing.
 *
 * This service evaluates edge conditions based on the current graph state
 * to determine which target nodes should be activated. This enables:
 * - Conditional branching based on state values.
 * - Loop-back routing for iterative processing.
 * - Dynamic workflow paths based on runtime data.
 *
 * Condition syntax supports:
 * - Simple value checks: "state.isComplete == true"
 * - Comparisons: "state.iterationCount < 10"
 * - Nested paths: "state.data.status == 'approved'"
 * - Iterator checks: "state.iterator.hasMore == true"
 *
 * @internal This class is not part of the public API and may change without notice.
 */
class ConditionalEdgeEvaluator {

  /**
   * The logger service.
   *
   * @var \Psr\Log\LoggerInterface
   */
  private readonly LoggerInterface $logger;

  /**
   * Constructs a new ConditionalEdgeEvaluator.
   *
   * @param \Psr\Log\LoggerInterface $logger
   *   The logger service.
   */
  public function __construct(LoggerInterface $logger) {
    $this->logger = $logger;
  }

  /**
   * Evaluates outgoing edges from a node based on state.
   *
   * @param string $sourceNodeId
   *   The source node ID.
   * @param \Drupal\flowdrop_stategraph\DTO\GraphState $state
   *   The current graph state.
   * @param \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineRuntimeInterface $pipeline
   *   The pipeline containing edge definitions.
   *
   * @return array<string>
   *   Array of target node IDs to route to.
   */
  public function evaluateEdges(
    string $sourceNodeId,
    GraphState $state,
    FlowDropPipelineRuntimeInterface $pipeline,
  ): array {
    $result = $this->evaluate($sourceNodeId, $state, $pipeline);
    return $result->targetNodeIds;
  }

  /**
   * Evaluates edges and returns detailed routing result.
   *
   * @param string $sourceNodeId
   *   The source node ID.
   * @param \Drupal\flowdrop_stategraph\DTO\GraphState $state
   *   The current graph state.
   * @param \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineRuntimeInterface $pipeline
   *   The pipeline.
   *
   * @return \Drupal\flowdrop_stategraph\Routing\RoutingResult
   *   The detailed routing result.
   */
  public function evaluate(
    string $sourceNodeId,
    GraphState $state,
    FlowDropPipelineRuntimeInterface $pipeline,
  ): RoutingResult {
    // Get workflow to access edge definitions.
    $workflow = $pipeline->getWorkflow();
    if ($workflow === NULL) {
      $this->logger->warning("Workflow not found for pipeline @id", [
        "@id" => $pipeline->id(),
      ]);
      return RoutingResult::noTargets($sourceNodeId);
    }

    // Get edges from workflow graph data.
    $graphData = $workflow->getGraphData();
    $edges = $graphData["edges"] ?? [];

    // Find outgoing edges from source node.
    $outgoingEdges = array_filter($edges, function (array $edge) use ($sourceNodeId): bool {
      return ($edge["source"] ?? "") === $sourceNodeId;
    });

    if (empty($outgoingEdges)) {
      return RoutingResult::noTargets($sourceNodeId);
    }

    // Check if any edges have conditions.
    $conditionalEdges = array_filter($outgoingEdges, function (array $edge): bool {
      $condition = $edge["data"]["condition"] ?? NULL;
      return $condition !== NULL && $condition !== "";
    });

    // If no conditional edges, return all targets (default routing).
    if (empty($conditionalEdges)) {
      $targetIds = array_map(function (array $edge): string {
        return $edge["target"] ?? "";
      }, $outgoingEdges);

      return RoutingResult::defaultRouting(
        $sourceNodeId,
        array_values(array_filter($targetIds))
      );
    }

    // Evaluate conditional edges.
    $matchedTargets = [];
    $evaluatedConditions = [];
    $stateArray = $state->toArray();

    foreach ($outgoingEdges as $edge) {
      $edgeId = $edge["id"] ?? "unknown";
      $targetId = $edge["target"] ?? "";
      $condition = $edge["data"]["condition"] ?? NULL;

      if ($targetId === "") {
        continue;
      }

      // No condition means always match (fallback edge).
      if ($condition === NULL || $condition === "") {
        // Only use as fallback if no other edge matches.
        continue;
      }

      // Evaluate condition.
      $result = $this->evaluateCondition($condition, $stateArray);
      $evaluatedConditions[$edgeId] = $result;

      if ($result) {
        $matchedTargets[] = $targetId;
        $this->logger->debug("Edge @edge matched: condition '@condition' is true", [
          "@edge" => $edgeId,
          "@condition" => $condition,
        ]);
      }
    }

    // If no conditional edges matched, try fallback edges (no condition).
    if (empty($matchedTargets)) {
      foreach ($outgoingEdges as $edge) {
        $condition = $edge["data"]["condition"] ?? NULL;
        if ($condition === NULL || $condition === "") {
          $targetId = $edge["target"] ?? "";
          if ($targetId !== "") {
            $matchedTargets[] = $targetId;
          }
        }
      }
    }

    return new RoutingResult(
      sourceNodeId: $sourceNodeId,
      targetNodeIds: array_unique($matchedTargets),
      evaluatedConditions: $evaluatedConditions,
      hasConditionalEdges: TRUE,
    );
  }

  /**
   * Evaluates a condition expression against state.
   *
   * @param string $condition
   *   The condition expression.
   * @param array<string, mixed> $state
   *   The state array.
   *
   * @return bool
   *   TRUE if condition matches.
   */
  public function evaluateCondition(string $condition, array $state): bool {
    try {
      // Parse condition into components.
      // Supported formats:
      // - "path == value"
      // - "path != value"
      // - "path < value"
      // - "path > value"
      // - "path <= value"
      // - "path >= value"
      // - "path" (truthy check).
      $condition = trim($condition);

      // Try to match comparison operators.
      $operators = ["===", "!==", "==", "!=", "<=", ">=", "<", ">"];
      $matchedOperator = NULL;
      $parts = [];

      foreach ($operators as $op) {
        if (str_contains($condition, $op)) {
          $parts = array_map("trim", explode($op, $condition, 2));
          if (count($parts) === 2) {
            $matchedOperator = $op;
            break;
          }
        }
      }

      // Simple truthy check if no operator found.
      if ($matchedOperator === NULL) {
        $value = $this->getValueFromPath($condition, $state);
        return (bool) $value;
      }

      // Get left and right values.
      $leftPath = $parts[0];
      $rightValue = $parts[1];

      $leftValue = $this->getValueFromPath($leftPath, $state);
      $rightValue = $this->parseValue($rightValue, $state);

      // Perform comparison.
      return $this->compare($leftValue, $matchedOperator, $rightValue);
    }
    catch (\Exception $e) {
      $this->logger->warning("Failed to evaluate condition '@condition': @error", [
        "@condition" => $condition,
        "@error" => $e->getMessage(),
      ]);
      return FALSE;
    }
  }

  /**
   * Gets a value from state using dot notation path.
   *
   * @param string $path
   *   The path (e.g., "state.data.status" or "isComplete").
   * @param array<string, mixed> $state
   *   The state array.
   *
   * @return mixed
   *   The value at the path or NULL.
   */
  private function getValueFromPath(string $path, array $state): mixed {
    // Remove "state." prefix if present.
    if (str_starts_with($path, "state.")) {
      $path = substr($path, 6);
    }

    $parts = explode(".", $path);
    $current = $state;

    foreach ($parts as $part) {
      if (!is_array($current)) {
        return NULL;
      }
      if (!array_key_exists($part, $current)) {
        return NULL;
      }
      $current = $current[$part];
    }

    return $current;
  }

  /**
   * Parses a value from the condition string.
   *
   * @param string $value
   *   The raw value string.
   * @param array<string, mixed> $state
   *   The state array (for path references).
   *
   * @return mixed
   *   The parsed value.
   */
  private function parseValue(string $value, array $state): mixed {
    $value = trim($value);

    // Boolean literals.
    if ($value === "true") {
      return TRUE;
    }
    if ($value === "false") {
      return FALSE;
    }

    // Null literal.
    if ($value === "null") {
      return NULL;
    }

    // Quoted string.
    if ((str_starts_with($value, '"') && str_ends_with($value, '"')) ||
        (str_starts_with($value, "'") && str_ends_with($value, "'"))) {
      return substr($value, 1, -1);
    }

    // Numeric value.
    if (is_numeric($value)) {
      return str_contains($value, ".") ? (float) $value : (int) $value;
    }

    // Otherwise, treat as path reference.
    return $this->getValueFromPath($value, $state);
  }

  /**
   * Compares two values using an operator.
   *
   * @param mixed $left
   *   The left value.
   * @param string $operator
   *   The comparison operator.
   * @param mixed $right
   *   The right value.
   *
   * @return bool
   *   The comparison result.
   */
  private function compare(mixed $left, string $operator, mixed $right): bool {
    return match ($operator) {
      "==" => $left == $right,
      "===" => $left === $right,
      "!=" => $left != $right,
      "!==" => $left !== $right,
      "<" => $left < $right,
      ">" => $left > $right,
      "<=" => $left <= $right,
      ">=" => $left >= $right,
      default => FALSE,
    };
  }

}
