<?php

declare(strict_types=1);

namespace Drupal\flowdrop_runtime\Service\Runtime;

use Drupal\flowdrop\Constants\ReservedName;
use Drupal\flowdrop\Utility\PortExposure;

/**
 * Decides which parameters a tool-exposed node leaves open for an LLM to fill.
 *
 * A param is *fillable* when it is a connectable input AND the workflow author
 * has left it **exposed**. A not-exposed param is neither shown to the model
 * (projected out of the tool schema) nor allowed to be overridden at call time
 * (filtered out of the model's args), so the author's configured value always
 * wins.
 *
 * Exposure is the v2 semantic, read
 * through {@see PortExposure}: a port's instance `config.ports` override, else
 * its metadata `exposedByDefault`. This **decouples pinning from value
 * presence** — the author can pin a param to empty, or expose a param while
 * giving it a default the model may override — which keying off
 * `array_key_exists()` (the superseded "rule A") could not express. The same
 * fillable set drives both projection (ToolProjector) and enforcement
 * (ScopedToolInvoker) so the schema the model sees and the args it may
 * influence never diverge; both must pass the same $parameterSchema.
 *
 * @internal
 */
final class ToolParameterScope {

  /**
   * Computes the fillable parameter names for a tool-exposed node.
   *
   * @param array<string, array<string, mixed>> $parameters
   *   The node type's parameter config map (name => flags), as returned by
   *   \Drupal\flowdrop_node_type\FlowDropNodeTypeInterface::getParameters().
   * @param array<string, mixed> $config
   *   The node instance's author-set configuration. The reserved `ports` key
   *   ({@see ReservedName::PORTS}) carries any per-port exposure overrides.
   * @param array<string, mixed> $parameterSchema
   *   The processor's getParameterSchema(). Read for each param's
   *   `x-exposed-by-default` (the metadata exposure default, used when the
   *   instance sets no explicit override). Defaults to empty (all params
   *   exposed-by-default).
   *
   * @return list<string>
   *   The names of connectable, exposed parameters, in declaration order.
   */
  public static function fillableNames(array $parameters, array $config, array $parameterSchema = []): array {
    $configPorts = is_array($config[ReservedName::PORTS] ?? NULL) ? $config[ReservedName::PORTS] : [];
    $properties = is_array($parameterSchema['properties'] ?? NULL) ? $parameterSchema['properties'] : [];

    $fillable = [];
    foreach ($parameters as $name => $flags) {
      $name = (string) $name;
      // Internal/system params (e.g. __iterator__) are never model-facing.
      if (str_starts_with($name, '__')) {
        continue;
      }
      $connectable = is_array($flags) && !empty($flags['connectable']);
      $portSchema = is_array($properties[$name] ?? NULL) ? $properties[$name] : [];
      $exposedByDefault = PortExposure::isExposedByDefault($portSchema);
      $exposed = PortExposure::isExposed($configPorts, ReservedName::PORT_INPUT, $name, $exposedByDefault);
      if ($connectable && $exposed) {
        $fillable[] = $name;
      }
    }
    return $fillable;
  }

  /**
   * Projects a parameter JSON schema down to the fillable subset.
   *
   * @param array<string, mixed> $schema
   *   A processor's getParameterSchema() (object schema with `properties` and
   *   optional `required`).
   * @param list<string> $fillable
   *   Fillable parameter names from ::fillableNames().
   *
   * @return array<string, mixed>
   *   The schema with `properties` and `required` restricted to $fillable.
   */
  public static function projectSchema(array $schema, array $fillable): array {
    $allowed = array_flip($fillable);
    if (isset($schema['properties']) && is_array($schema['properties'])) {
      $schema['properties'] = array_intersect_key($schema['properties'], $allowed);
    }
    if (isset($schema['required']) && is_array($schema['required'])) {
      $schema['required'] = array_values(array_filter(
        $schema['required'],
        static fn ($name): bool => isset($allowed[(string) $name]),
      ));
    }
    return $schema;
  }

  /**
   * Normalizes a projected schema into provider-safe standard JSON Schema.
   *
   * FlowDrop's parameter schemas use a relaxed internal dialect that providers
   * (Anthropic, OpenAI, …) reject or silently ignore when the schema is handed
   * over as a tool's `input_schema`. This rewrites the two divergences that
   * actually reach a model when an arbitrary node is exposed as a tool:
   *
   * - **Per-property `required` booleans.** A property carrying
   *   `'required' => TRUE` is the property-input dialect; standard JSON Schema
   *   expresses required-ness as a `required` array on the enclosing object.
   *   The boolean is lifted into (and merged with) the object's `required`
   *   array, then removed from the property. A consumer no longer needs to
   *   strip it client-side.
   * - **Non-standard `type` values.** `'mixed'` / `'any'` are not JSON Schema
   *   types; omitting `type` is the valid "any type" form, so they are dropped.
   *
   * Nested object `properties` and array `items` are normalized recursively so
   * structured params are covered too. All other keys are passed through
   * untouched.
   *
   * @param array<string, mixed> $schema
   *   A (typically already projected) parameter schema.
   *
   * @return array<string, mixed>
   *   The schema normalized to standard JSON Schema.
   */
  public static function normalizeForModel(array $schema): array {
    // Drop non-standard scalar type declarations ("any type" == no `type`).
    if (isset($schema['type']) && is_string($schema['type'])
      && in_array(strtolower($schema['type']), ['mixed', 'any'], TRUE)) {
      unset($schema['type']);
    }

    if (isset($schema['properties']) && is_array($schema['properties'])) {
      $lifted = [];
      foreach ($schema['properties'] as $name => $property) {
        if (!is_array($property)) {
          continue;
        }
        // Lift a per-property `required: true` into the object's required list.
        if (array_key_exists('required', $property)) {
          if (!empty($property['required'])) {
            $lifted[] = (string) $name;
          }
          unset($property['required']);
        }
        $schema['properties'][$name] = self::normalizeForModel($property);
      }

      if ($lifted !== []) {
        $existing = (isset($schema['required']) && is_array($schema['required']))
          ? array_map('strval', $schema['required'])
          : [];
        $schema['required'] = array_values(array_unique([...$existing, ...$lifted]));
      }
    }

    // Normalize the element schema of an array property.
    if (isset($schema['items']) && is_array($schema['items'])) {
      $schema['items'] = self::normalizeForModel($schema['items']);
    }

    return $schema;
  }

  /**
   * Drops any pinned args, keeping only the model's fillable values.
   *
   * @param array<string, mixed> $args
   *   The model-supplied tool-call arguments.
   * @param list<string> $fillable
   *   Fillable parameter names from ::fillableNames().
   *
   * @return array<string, mixed>
   *   The args restricted to fillable keys.
   */
  public static function filterArgs(array $args, array $fillable): array {
    return array_intersect_key($args, array_flip($fillable));
  }

}
