<?php

declare(strict_types=1);

namespace Drupal\flowdrop_runtime\Service;

use Drupal\flowdrop\Constants\ReservedName;
use Drupal\flowdrop\DTO\NodeMetadata\NodePort;
use Drupal\flowdrop\DTO\ParameterBag;
use Drupal\flowdrop\DTO\ParameterBagInterface;
use Drupal\flowdrop\Utility\PortExposure;
use Drupal\flowdrop_runtime\Exception\MissingParameterException;
use Drupal\flowdrop_runtime\Exception\ParameterValidationException;
use Psr\Log\LoggerInterface;

/**
 * Service for resolving parameters from multiple sources.
 *
 * This service implements the core parameter resolution algorithm
 * for the Unified Parameter System. It takes parameter definitions
 * from the plugin schema, entity configuration, workflow values,
 * and runtime inputs, and resolves them into a single ParameterBag.
 *
 * Resolution Priority (highest to lowest):
 * 1. Runtime input (from upstream node connection) - if connectable=TRUE
 *    OR if the parameter is internal (name starts with "__")
 * 2. Workflow value (set by user in workflow editor) - if configurable=TRUE
 * 3. Entity default (from config entity YAML)
 * 4. Schema default (from plugin's getParameterSchema())
 *
 * Internal Parameters:
 * Parameters with names starting with "__" (e.g., __iterator__, __context__)
 * are treated as internal/system parameters. They:
 * - Always accept runtime inputs (bypass connectable check)
 * - Cannot be configured by users (bypass configurable check)
 * - Are typically injected by the orchestrator for internal state management
 * - Should not be exposed to end users in the workflow editor
 *
 * @see docs/development/internal-parameters.md
 *
 * Execution Context Access:
 * For processors that need access to workflow execution context (initial
 * data, workflow ID, etc.), implement ExecutionContextAwareInterface.
 * The runtime will automatically inject an ExecutionContextDTO with
 * type-safe access to context data.
 *
 * Flag Resolution (entity config → system defaults):
 * - connectable: entity config, else FALSE (bypassed for internal params)
 * - configurable: entity config, else FALSE
 * - required: entity config, else FALSE
 *
 * @internal This class is not part of the public API and may change without notice.
 *
 * @see \Drupal\flowdrop_runtime\Service\ParameterResolverInterface
 * @see \Drupal\flowdrop\Plugin\FlowDropNodeProcessor\ExecutionContextAwareInterface
 * @see docs/development/flowdrop-node-processor.md
 */
class ParameterResolver implements ParameterResolverInterface {

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

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

  /**
   * {@inheritdoc}
   */
  public function resolve(
    array $parameterSchema,
    array $entityConfig,
    array $workflowValues,
    array $runtimeInputs,
  ): ParameterBagInterface {
    // Step 1: Decompose unified "input" port (reserved name) and merge with
    // individual inputs. Individual ports take precedence when both provide
    // a value for the same key. Only connectable parameters and internal
    // parameters are accepted from the unified input.
    $runtimeInputs = $this->mergeUnifiedInput($runtimeInputs, $entityConfig, $workflowValues);

    $resolved = [];

    $properties = $parameterSchema["properties"] ?? [];

    // Per-instance port exposure overrides live in the reserved `ports` config
    // key (workflow values are the node's data.config). In v2 a not-exposed
    // input port is not runtime-overridable: it must not take a runtime/LLM
    // value, so the author's config wins (Priority 2/3).
    $configPorts = is_array($workflowValues[ReservedName::PORTS] ?? NULL) ? $workflowValues[ReservedName::PORTS] : [];

    foreach ($properties as $name => $schema) {
      $config = $entityConfig[$name] ?? [];

      // Get flags from entity config with system defaults.
      // Plugins no longer declare these - entity has full control.
      $configurable = $config["configurable"] ?? FALSE;
      $connectable = $config["connectable"] ?? FALSE;
      $required = $config["required"] ?? FALSE;

      // Effective exposure: instance override (config.ports) else the port's
      // metadata default (`x-exposed-by-default`, defaulting to exposed).
      $exposedByDefault = PortExposure::isExposedByDefault($schema);
      $exposed = PortExposure::isExposed($configPorts, ReservedName::PORT_INPUT, (string) $name, $exposedByDefault);

      // Get default value (entity overrides schema).
      $schemaDefault = $schema["default"] ?? NULL;
      $entityDefault = $config["default"] ?? NULL;
      $default = $entityDefault ?? $schemaDefault;

      // Resolve value based on priority.
      $value = $this->resolveValue(
            $name,
            $connectable,
            $configurable,
            $runtimeInputs,
            $workflowValues,
            $default,
            $exposed
        );

      // Check required constraint.
      if ($required && $value === NULL) {
        throw new MissingParameterException(
          $name,
          "Required parameter '{$name}' is missing. " .
          "Ensure it is either connected (connectable: true) or has a default value."
          );
      }

      // Validate value against schema (strict).
      if ($value !== NULL) {
        $this->validateValue($name, $value, $schema);
      }

      $resolved[$name] = $value;

      $this->logger->debug("Resolved parameter '@name': @value", [
        "@name" => $name,
        "@value" => is_scalar($value) ? (string) $value : json_encode($value),
      ]);
    }

    // Forward dynamic input port VALUES from runtime inputs.
    // Dynamic ports are user-defined per node instance. Their port names
    // (e.g., "first_name", "age") are NOT in the plugin's parameterSchema,
    // so the main loop above skips them. We forward matching runtime
    // inputs so the processor can access the connected values.
    // The dynamicInputs/dynamicOutputs definitions themselves are already
    // resolved by the main loop (they're in the parameter schema).
    $dynamicInputDefs = $resolved[ReservedName::DYNAMIC_INPUTS] ?? $workflowValues[ReservedName::DYNAMIC_INPUTS] ?? [];
    if (is_array($dynamicInputDefs)) {
      foreach ($dynamicInputDefs as $dynPort) {
        if (!is_array($dynPort)) {
          continue;
        }
        $dynName = NodePort::fromArray($dynPort)->name;
        // Skip empty names and names that collide with static params.
        if ($dynName !== '' && !array_key_exists($dynName, $resolved)
            && array_key_exists($dynName, $runtimeInputs)) {
          $resolved[$dynName] = $runtimeInputs[$dynName];
          $this->logger->debug("Dynamic port '@name' resolved from runtime input", [
            '@name' => $dynName,
          ]);
        }
      }
    }

    return new ParameterBag($resolved);
  }

  /**
   * Resolve a single parameter value.
   *
   * @param string $name
   *   Parameter name.
   * @param bool $connectable
   *   Whether parameter accepts connections.
   * @param bool $configurable
   *   Whether parameter is configurable.
   * @param array<string, mixed> $runtimeInputs
   *   Runtime input values.
   * @param array<string, mixed> $workflowValues
   *   Workflow configuration values.
   * @param mixed $default
   *   Default value.
   * @param bool $exposed
   *   Whether the param's input port is exposed for this instance. A
   *   not-exposed connectable param is not runtime-overridable, so it does not
   *   accept a runtime/LLM value (Priority 1 is skipped). Internal params are
   *   unaffected. Defaults to TRUE (exposed) for callers that do not gate.
   *
   * @return mixed
   *   The resolved value.
   */
  private function resolveValue(
    string $name,
    bool $connectable,
    bool $configurable,
    array $runtimeInputs,
    array $workflowValues,
    mixed $default,
    bool $exposed = TRUE,
  ): mixed {
    // Check if this is an internal parameter (name starts with "__").
    $isInternal = $this->isInternalParameter($name);

    // Priority 1: Runtime input.
    // - Internal parameters (starting with "__") always accept runtime inputs.
    // - Regular parameters require connectable=TRUE AND exposed: a not-exposed
    //   port is not runtime-overridable (v2 semantic exposure), so a runtime
    //   value is ignored and the author's config wins below.
    if (($isInternal || ($connectable && $exposed)) && array_key_exists($name, $runtimeInputs)) {
      $source = $isInternal ? "runtime input (internal parameter)" : "runtime input";
      $this->logger->debug("Parameter '@name' resolved from @source", [
        "@name" => $name,
        "@source" => $source,
      ]);
      return $runtimeInputs[$name];
    }

    // Priority 2: Workflow value (if configurable and provided).
    // Internal parameters cannot be configured by users - they are system-only.
    if (!$isInternal && $configurable && array_key_exists($name, $workflowValues)) {
      $this->logger->debug("Parameter '@name' resolved from workflow value", ["@name" => $name]);
      return $workflowValues[$name];
    }

    // Priority 3: Default value.
    $this->logger->debug("Parameter '@name' resolved from default", ["@name" => $name]);
    return $default;
  }

  /**
   * Check if a parameter is an internal/system parameter.
   *
   * Internal parameters have names starting with "__" (double underscore).
   * These parameters are used for internal state management and always
   * accept runtime inputs regardless of the connectable flag.
   *
   * Examples: __iterator__, __context__, __state__
   *
   * @param string $name
   *   The parameter name.
   *
   * @return bool
   *   TRUE if the parameter is internal, FALSE otherwise.
   */
  private function isInternalParameter(string $name): bool {
    return str_starts_with($name, "__");
  }

  /**
   * Check if a key matches a dynamic port name.
   *
   * Dynamic port names are user-defined per node instance and stored
   * in workflowValues['dynamicInputs']. They are not in entityConfig
   * because they vary per instance, not per node type.
   *
   * @param string $key
   *   The key to check.
   * @param array<string, mixed> $workflowValues
   *   Workflow values that may contain dynamicInputs definitions.
   *
   * @return bool
   *   TRUE if the key matches a defined dynamic port name.
   */
  private function isDynamicPortName(string $key, array $workflowValues): bool {
    $dynamicInputDefs = $workflowValues[ReservedName::DYNAMIC_INPUTS] ?? [];
    if (!is_array($dynamicInputDefs)) {
      return FALSE;
    }
    foreach ($dynamicInputDefs as $def) {
      if (is_array($def) && NodePort::fromArray($def)->name === $key) {
        return TRUE;
      }
    }
    return FALSE;
  }

  /**
   * Merge unified "input" port (reserved name) with individual inputs.
   *
   * When both the unified "input" port AND individual ports are connected:
   * - Values from "input" are decomposed and used as base.
   * - Individual port values take precedence (completely replace).
   * - Only keys that are connectable=TRUE, internal (__*), or matching
   *   a dynamic port name are accepted.
   *
   * This allows workflows to pass bulk data via "input" while overriding
   * specific values through individual connections.
   *
   * @param array<string, mixed> $runtimeInputs
   *   Runtime inputs including potential "input" key.
   * @param array<string, array<string, mixed>> $entityConfig
   *   Entity parameter configuration with connectable flags.
   * @param array<string, mixed> $workflowValues
   *   Workflow values containing dynamic port definitions.
   *
   * @return array<string, mixed>
   *   Merged inputs with "input" decomposed and filtered.
   */
  private function mergeUnifiedInput(array $runtimeInputs, array $entityConfig, array $workflowValues = []): array {
    // If no unified "input" port, return as-is.
    if (!array_key_exists(ReservedName::PORT_INPUT, $runtimeInputs)) {
      return $runtimeInputs;
    }

    $unifiedInput = $runtimeInputs[ReservedName::PORT_INPUT];

    // If "input" is not an array/object, skip decomposition.
    if (!is_array($unifiedInput)) {
      $this->logger->warning("Unified 'input' value is not an array, skipping decomposition");
      return $runtimeInputs;
    }

    $merged = [];

    // Filter unified input: only include keys that are connectable or internal.
    foreach ($unifiedInput as $key => $value) {
      $paramConfig = $entityConfig[$key] ?? [];
      $connectable = $paramConfig["connectable"] ?? FALSE;

      // Internal parameters (starting with __) bypass connectable check.
      $isInternal = $this->isInternalParameter($key);

      // Dynamic port names are instance-specific (not in entityConfig),
      // so they also bypass the connectable check.
      $isDynamicPort = $this->isDynamicPortName($key, $workflowValues);

      if ($connectable || $isInternal || $isDynamicPort) {
        $merged[$key] = $value;
      }
      else {
        $this->logger->debug("Filtered non-connectable key '@key' from unified input", [
          "@key" => $key,
        ]);
      }
    }

    // Overlay individual port values (they take precedence).
    // Individual port completely replaces the unified input value for each key.
    foreach ($runtimeInputs as $key => $value) {
      // Skip the "input" key itself - we've already processed it.
      if ($key === ReservedName::PORT_INPUT) {
        continue;
      }
      // Individual port value overwrites any value from unified input.
      $merged[$key] = $value;
    }

    $this->logger->debug("Merged unified input with individual ports: @keys", [
      "@keys" => implode(", ", array_keys($merged)),
    ]);

    return $merged;
  }

  /**
   * Validate a parameter value against its schema.
   *
   * @param string $name
   *   Parameter name.
   * @param mixed $value
   *   Parameter value.
   * @param array<string, mixed> $schema
   *   Parameter schema.
   *
   * @throws \Drupal\flowdrop_runtime\Exception\ParameterValidationException
   *   When validation fails.
   */
  private function validateValue(string $name, mixed $value, array $schema): void {
    // Type validation.
    $expectedType = $schema["type"] ?? "mixed";
    if ($expectedType !== "mixed" && !$this->isValidType($value, $expectedType)) {
      $actualType = gettype($value);
      throw new ParameterValidationException(
            $name,
            $value,
            "type",
            "Parameter '{$name}' expects type '{$expectedType}', got '{$actualType}'"
        );
    }

    // Enum validation.
    if (isset($schema["enum"])) {
      if (!in_array($value, $schema["enum"], TRUE)) {
        $allowed = implode(", ", array_map(
              fn($v): string => is_string($v) ? "'{$v}'" : (string) $v,
              $schema["enum"]
          ));
        throw new ParameterValidationException(
              $name,
              $value,
              "enum",
              "Parameter '{$name}' received invalid value. Allowed values: {$allowed}"
          );
      }
    }

    // Numeric constraints.
    if (is_numeric($value)) {
      $numericValue = (float) $value;

      if (isset($schema["minimum"]) && $numericValue < $schema["minimum"]) {
        throw new ParameterValidationException(
              $name,
              $value,
              "minimum",
              "Parameter '{$name}' value {$value} is below minimum {$schema["minimum"]}"
          );
      }

      if (isset($schema["maximum"]) && $numericValue > $schema["maximum"]) {
        throw new ParameterValidationException(
              $name,
              $value,
              "maximum",
              "Parameter '{$name}' value {$value} exceeds maximum {$schema["maximum"]}"
          );
      }

      if (isset($schema["exclusiveMinimum"]) && $numericValue <= $schema["exclusiveMinimum"]) {
        throw new ParameterValidationException(
              $name,
              $value,
              "exclusiveMinimum",
              "Parameter '{$name}' value {$value} must be greater than {$schema["exclusiveMinimum"]}"
          );
      }

      if (isset($schema["exclusiveMaximum"]) && $numericValue >= $schema["exclusiveMaximum"]) {
        throw new ParameterValidationException(
              $name,
              $value,
              "exclusiveMaximum",
              "Parameter '{$name}' value {$value} must be less than {$schema["exclusiveMaximum"]}"
          );
      }

      if (isset($schema["multipleOf"]) && fmod($numericValue, $schema["multipleOf"]) !== 0.0) {
        throw new ParameterValidationException(
              $name,
              $value,
              "multipleOf",
              "Parameter '{$name}' value {$value} must be a multiple of {$schema["multipleOf"]}"
          );
      }
    }

    // String constraints.
    if (is_string($value)) {
      $length = mb_strlen($value);

      if (isset($schema["minLength"]) && $length < $schema["minLength"]) {
        throw new ParameterValidationException(
              $name,
              $value,
              "minLength",
              "Parameter '{$name}' length {$length} is below minimum {$schema["minLength"]}"
          );
      }

      if (isset($schema["maxLength"]) && $length > $schema["maxLength"]) {
        throw new ParameterValidationException(
              $name,
              $value,
              "maxLength",
              "Parameter '{$name}' length {$length} exceeds maximum {$schema["maxLength"]}"
          );
      }

      if (isset($schema["pattern"])) {
        $pattern = "/" . $schema["pattern"] . "/u";
        if (!preg_match($pattern, $value)) {
          throw new ParameterValidationException(
                $name,
                $value,
                "pattern",
                "Parameter '{$name}' does not match required pattern '{$schema["pattern"]}'"
            );
        }
      }

      // Format validation (common formats).
      if (isset($schema["format"])) {
        $this->validateFormat($name, $value, $schema["format"]);
      }
    }

    // Array constraints.
    if (is_array($value)) {
      $count = count($value);

      if (isset($schema["minItems"]) && $count < $schema["minItems"]) {
        throw new ParameterValidationException(
              $name,
              $value,
              "minItems",
              "Parameter '{$name}' has {$count} items, minimum is {$schema["minItems"]}"
          );
      }

      if (isset($schema["maxItems"]) && $count > $schema["maxItems"]) {
        throw new ParameterValidationException(
              $name,
              $value,
              "maxItems",
              "Parameter '{$name}' has {$count} items, maximum is {$schema["maxItems"]}"
          );
      }

      if (isset($schema["uniqueItems"]) && $schema["uniqueItems"]) {
        $unique = array_unique($value, SORT_REGULAR);
        if (count($unique) !== $count) {
          throw new ParameterValidationException(
                $name,
                $value,
                "uniqueItems",
                "Parameter '{$name}' must have unique items"
            );
        }
      }
    }
  }

  /**
   * Validate a string value against a format.
   *
   * @param string $name
   *   Parameter name.
   * @param string $value
   *   The string value.
   * @param string $format
   *   The expected format.
   *
   * @throws \Drupal\flowdrop_runtime\Exception\ParameterValidationException
   *   When format validation fails.
   */
  private function validateFormat(string $name, string $value, string $format): void {
    $valid = match ($format) {
      "email" => filter_var($value, FILTER_VALIDATE_EMAIL) !== FALSE,
            "uri", "url" => filter_var($value, FILTER_VALIDATE_URL) !== FALSE,
            "date" => $this->isValidDate($value, "Y-m-d"),
            "date-time" => $this->isValidDate($value, \DateTimeInterface::RFC3339) ||
                           $this->isValidDate($value, \DateTimeInterface::ATOM) ||
                           $this->isValidDate($value, "Y-m-d\TH:i:s.u\Z") ||
                           $this->isValidDate($value, "Y-m-d\TH:i:sP"),
            "time" => $this->isValidDate($value, "H:i:s") ||
                      $this->isValidDate($value, "H:i"),
            "ipv4" => filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== FALSE,
            "ipv6" => filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== FALSE,
            "uuid" => preg_match(
            "/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i",
            $value
        ) === 1,
            "hostname" => preg_match(
            "/^(?=.{1,253}$)(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.[A-Za-z0-9-]{1,63})*$/",
            $value
        ) === 1,
      // Unknown formats pass validation.
            default => TRUE,
    };

    if (!$valid) {
      throw new ParameterValidationException(
            $name,
            $value,
            "format",
            "Parameter '{$name}' is not a valid {$format}"
        );
    }
  }

  /**
   * Check if a date string is valid.
   *
   * @param string $value
   *   The date string.
   * @param string $format
   *   The expected format.
   *
   * @return bool
   *   TRUE if valid.
   */
  private function isValidDate(string $value, string $format): bool {
    $date = \DateTimeImmutable::createFromFormat($format, $value);
    return $date !== FALSE && $date->format($format) === $value;
  }

  /**
   * Check if a value matches the expected type.
   *
   * @param mixed $value
   *   The value to check.
   * @param string $expectedType
   *   The expected type.
   *
   * @return bool
   *   TRUE if type matches.
   */
  private function isValidType(mixed $value, string $expectedType): bool {
    return match ($expectedType) {
      "string" => is_string($value),
            "integer", "int" => is_int($value),
            "number" => is_int($value) || is_float($value),
            "boolean", "bool" => is_bool($value),
            "array" => is_array($value),
            "object" => is_array($value) || is_object($value),
            "null" => $value === NULL,
            "mixed" => TRUE,
            default => TRUE,
    };
  }

}
