<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_runtime\Unit\Service\Runtime;

use Drupal\Tests\UnitTestCase;
use Drupal\flowdrop_runtime\Service\Runtime\ToolParameterScope;

/**
 * Tests the fillable/pinned parameter split for tool-exposed nodes.
 *
 * Phase B (semantic exposure): a param is fillable when it is connectable AND
 * exposed — its `data.config.ports` override, else its metadata
 * `exposedByDefault`. This decouples pinning from value presence (the
 * superseded "rule A"): the author can pin a param to empty, or expose a param
 * that carries a configured default the model may override. Not-exposed params
 * are projected out of the model-facing schema and stripped from model args.
 *
 * @coversDefaultClass \Drupal\flowdrop_runtime\Service\Runtime\ToolParameterScope
 * @group flowdrop_runtime
 */
class ToolParameterScopeTest extends UnitTestCase {

  /**
   * The parameter map covering every connectable/configured combination.
   *
   * @return array<string, array<string, mixed>>
   *   A node-type parameters map (name => flags).
   */
  private function parameters(): array {
    return [
      // Connectable, exposed by default -> fillable.
      'query' => ['connectable' => TRUE, 'configurable' => TRUE],
      // Connectable, exposed by default -> fillable unless the author hides it.
      'url' => ['connectable' => TRUE, 'configurable' => TRUE],
      // Not connectable -> never fillable, regardless of exposure.
      'method' => ['connectable' => FALSE, 'configurable' => TRUE],
      // Internal/system param -> never model-facing.
      '__iterator__' => ['connectable' => TRUE],
    ];
  }

  /**
   * Builds a `config.ports` value hiding the named input ports.
   *
   * @param list<string> $hidden
   *   Input port names to mark not-exposed.
   *
   * @return array<string, mixed>
   *   A node config carrying the `ports` reserved key.
   */
  private function configHiding(array $hidden): array {
    $inputs = array_map(static fn (string $id): array => ['id' => $id, 'exposed' => FALSE], $hidden);
    return ['ports' => ['inputs' => $inputs]];
  }

  /**
   * Connectable, exposed params are fillable; non-connectable/internal are not.
   *
   * @covers ::fillableNames
   */
  public function testFillableIsConnectableAndExposed(): void {
    $fillable = ToolParameterScope::fillableNames($this->parameters(), []);

    // Both connectable params are exposed by default; 'method'
    // (non-connectable) and '__iterator__' (internal) are excluded.
    $this->assertSame(['query', 'url'], $fillable);
  }

  /**
   * A not-exposed connectable param is pinned (projected/stripped out).
   *
   * @covers ::fillableNames
   */
  public function testNotExposedParamIsPinned(): void {
    $fillable = ToolParameterScope::fillableNames($this->parameters(), $this->configHiding(['url']));

    $this->assertSame(['query'], $fillable);
  }

  /**
   * Value presence no longer pins: an exposed param with a value is fillable.
   *
   * This is the Phase B reversal of the superseded value-presence rule — it
   * makes "expose a param while giving it a default the model may override"
   * possible.
   *
   * @covers ::fillableNames
   */
  public function testValuePresenceDoesNotPin(): void {
    // Author set values but exposed nothing-out via config.ports.
    $config = ['url' => 'https://api.example.com', 'query' => NULL];

    $fillable = ToolParameterScope::fillableNames($this->parameters(), $config);

    // Both remain fillable — exposure, not value presence, decides.
    $this->assertSame(['query', 'url'], $fillable);
  }

  /**
   * A param shipping `x-exposed-by-default: false` is pinned until exposed.
   *
   * @covers ::fillableNames
   */
  public function testExposedByDefaultFalseParamPinnedUnlessExposed(): void {
    $schema = ['properties' => ['url' => ['x-exposed-by-default' => FALSE]]];

    // Default-not-exposed -> 'url' is pinned.
    $pinned = ToolParameterScope::fillableNames($this->parameters(), [], $schema);
    $this->assertSame(['query'], $pinned);

    // Instance exposes it -> fillable again.
    $exposed = ToolParameterScope::fillableNames(
      $this->parameters(),
      ['ports' => ['inputs' => [['id' => 'url', 'exposed' => TRUE]]]],
      $schema,
    );
    $this->assertSame(['query', 'url'], $exposed);
  }

  /**
   * @covers ::projectSchema
   */
  public function testProjectSchemaRestrictsPropertiesAndRequired(): void {
    $schema = [
      'type' => 'object',
      'properties' => [
        'query' => ['type' => 'string'],
        'url' => ['type' => 'string'],
        'method' => ['type' => 'string'],
      ],
      'required' => ['query', 'url'],
    ];

    $projected = ToolParameterScope::projectSchema($schema, ['query']);

    $this->assertSame(['query'], array_keys($projected['properties']));
    $this->assertSame(['query'], $projected['required']);
    // Untouched top-level keys survive.
    $this->assertSame('object', $projected['type']);
  }

  /**
   * Projecting to an empty fillable set yields empty properties + required.
   *
   * @covers ::projectSchema
   */
  public function testProjectSchemaToEmptySet(): void {
    $schema = [
      'type' => 'object',
      'properties' => ['url' => ['type' => 'string']],
      'required' => ['url'],
    ];

    $projected = ToolParameterScope::projectSchema($schema, []);

    $this->assertSame([], $projected['properties']);
    $this->assertSame([], $projected['required']);
  }

  /**
   * Per-property `required: true` is lifted into the object's required array.
   *
   * @covers ::normalizeForModel
   */
  public function testNormalizeLiftsPerPropertyRequired(): void {
    $schema = [
      'type' => 'object',
      'properties' => [
        'a' => ['type' => 'string', 'required' => TRUE],
        'b' => ['type' => 'string', 'required' => FALSE],
        'c' => ['type' => 'string'],
      ],
    ];

    $normalized = ToolParameterScope::normalizeForModel($schema);

    $this->assertSame(['a'], $normalized['required']);
    // The per-property boolean is removed from every property.
    $this->assertArrayNotHasKey('required', $normalized['properties']['a']);
    $this->assertArrayNotHasKey('required', $normalized['properties']['b']);
  }

  /**
   * Lifted required names merge with (and dedupe against) an existing array.
   *
   * @covers ::normalizeForModel
   */
  public function testNormalizeMergesWithExistingRequiredArray(): void {
    $schema = [
      'type' => 'object',
      'properties' => [
        'a' => ['type' => 'string', 'required' => TRUE],
        'b' => ['type' => 'string', 'required' => TRUE],
      ],
      'required' => ['a'],
    ];

    $normalized = ToolParameterScope::normalizeForModel($schema);

    $this->assertSame(['a', 'b'], $normalized['required']);
  }

  /**
   * Non-standard `mixed`/`any` types are dropped (the valid "any type" form).
   *
   * @covers ::normalizeForModel
   */
  public function testNormalizeDropsNonStandardTypes(): void {
    $schema = [
      'type' => 'object',
      'properties' => [
        'a' => ['type' => 'mixed'],
        'b' => ['type' => 'ANY'],
        'c' => ['type' => 'string'],
      ],
    ];

    $normalized = ToolParameterScope::normalizeForModel($schema);

    $this->assertArrayNotHasKey('type', $normalized['properties']['a']);
    $this->assertArrayNotHasKey('type', $normalized['properties']['b']);
    $this->assertSame('string', $normalized['properties']['c']['type']);
  }

  /**
   * Nested object properties and array items are normalized recursively.
   *
   * @covers ::normalizeForModel
   */
  public function testNormalizeRecursesIntoNestedSchemas(): void {
    $schema = [
      'type' => 'object',
      'properties' => [
        'nested' => [
          'type' => 'object',
          'properties' => [
            'inner' => ['type' => 'mixed', 'required' => TRUE],
          ],
        ],
        'list' => [
          'type' => 'array',
          'items' => ['type' => 'any'],
        ],
      ],
    ];

    $normalized = ToolParameterScope::normalizeForModel($schema);

    $this->assertSame(['inner'], $normalized['properties']['nested']['required']);
    $this->assertArrayNotHasKey('type', $normalized['properties']['nested']['properties']['inner']);
    $this->assertArrayNotHasKey('type', $normalized['properties']['list']['items']);
  }

  /**
   * A standard schema (top-level required array, valid types) is unchanged.
   *
   * @covers ::normalizeForModel
   */
  public function testNormalizeLeavesStandardSchemaUntouched(): void {
    $schema = [
      'type' => 'object',
      'properties' => [
        'a' => ['type' => 'string', 'description' => 'A.'],
        'b' => ['type' => 'integer'],
      ],
      'required' => ['a'],
    ];

    $this->assertSame($schema, ToolParameterScope::normalizeForModel($schema));
  }

  /**
   * @covers ::filterArgs
   */
  public function testFilterArgsDropsPinnedKeys(): void {
    $args = [
      'query' => 'cats',
      'url' => 'https://evil.example.com',
      'unknown' => 'x',
    ];

    $filtered = ToolParameterScope::filterArgs($args, ['query']);

    // Pinned 'url' (and any unrecognised key) are dropped; the model cannot
    // override the author's configured value.
    $this->assertSame(['query' => 'cats'], $filtered);
  }

}
