<?php

namespace Tests\Feature;

use App\Agent\ToolRegistry;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
use Illuminate\JsonSchema\Serializer;
use Tests\TestCase;

/**
 * Every tool's arguments have to be a schema the provider will accept.
 *
 * The whole tool list is sent on every turn, so one malformed declaration does
 * not break one tool, it returns 400 and stops the entire chat. That is exactly
 * what happened on 21 Sep: a new array parameter was declared without saying
 * what it contained, and Gemini refused every request with
 * "GenerateContentRequest.tools[0].function_declarations[2].parameters…".
 *
 * Nothing caught it, because every other test fakes the agent and so never
 * builds a provider schema at all. These assert the shape directly.
 */
class ToolSchemaIsValidTest extends TestCase
{
    use RefreshDatabase;

    /** @return list<array{name: string, arguments: array<string, mixed>}> */
    private function declarations(): array
    {
        // Built exactly the way the provider gateways build it, so this sees
        // the same json the model is sent rather than an approximation of it.
        return collect(app(ToolRegistry::class)->resolve())
            ->map(fn ($tool): array => [
                'name' => $tool->name(),
                'arguments' => collect($tool->schema(new JsonSchemaTypeFactory))
                    ->map(fn ($argument): array => Serializer::serialize($argument))
                    ->all(),
            ])
            ->values()
            ->all();
    }

    /**
     * An array parameter must say what it contains.
     *
     * Gemini rejects the request outright without it; OpenAI accepts it but
     * then has nothing to guide what it puts in the list.
     */
    public function test_every_array_argument_declares_its_items(): void
    {
        $declarations = $this->declarations();
        $this->assertNotEmpty($declarations);

        foreach ($declarations as $tool) {
            foreach ($tool['arguments'] as $name => $argument) {
                if (($argument['type'] ?? null) !== 'array') {
                    continue;
                }

                $this->assertArrayHasKey(
                    'items',
                    $argument,
                    "[{$tool['name']}] declares [{$name}] as an array without items(). "
                    .'Gemini answers the whole turn with a 400, so this breaks the chat, not just this tool.',
                );
            }
        }
    }

    /**
     * An object parameter must say what is in it.
     *
     * A free-form map has no declarable shape, so a provider that validates the
     * schema refuses it. Declare the keys, or use a list of pairs instead.
     */
    public function test_every_object_argument_declares_its_properties(): void
    {
        $declarations = $this->declarations();
        $this->assertNotEmpty($declarations);

        foreach ($declarations as $tool) {
            foreach ($tool['arguments'] as $name => $argument) {
                if (($argument['type'] ?? null) !== 'object') {
                    continue;
                }

                $this->assertNotEmpty(
                    $argument['properties'] ?? [],
                    "[{$tool['name']}] declares [{$name}] as an object with no properties. "
                    .'Name the keys, or take a list of pairs instead.',
                );
            }
        }
    }

    /** Every argument needs a type at all, or the provider cannot validate it. */
    public function test_every_argument_has_a_type(): void
    {
        $declarations = $this->declarations();
        $this->assertNotEmpty($declarations);

        foreach ($declarations as $tool) {
            foreach ($tool['arguments'] as $name => $argument) {
                $this->assertArrayHasKey(
                    'type',
                    $argument,
                    "[{$tool['name']}] declares [{$name}] with no type.",
                );
            }
        }
    }

    /** The registry has to resolve at all, which a bad binding would stop. */
    public function test_every_registered_tool_resolves(): void
    {
        $tools = app(ToolRegistry::class)->resolve();

        $this->assertNotEmpty($tools);

        foreach ($tools as $tool) {
            $this->assertMatchesRegularExpression(
                '/^[a-z][a-z0-9_]*__[a-z][a-z0-9_]*$/',
                $tool->name(),
                'Providers reject tool names outside [a-zA-Z0-9_-].',
            );
        }
    }
}
