<?php

/*
 * This file is part of the Symfony package.
 *
 * (c) Fabien Potencier <fabien@symfony.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Symfony\AI\Agent\Toolbox;

use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\AI\Agent\Toolbox\Event\ToolCallArgumentsResolved;
use Symfony\AI\Agent\Toolbox\Event\ToolCallFailed;
use Symfony\AI\Agent\Toolbox\Event\ToolCallRequested;
use Symfony\AI\Agent\Toolbox\Event\ToolCallSucceeded;
use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionException;
use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionExceptionInterface;
use Symfony\AI\Agent\Toolbox\Exception\ToolNotFoundException;
use Symfony\AI\Agent\Toolbox\Source\HasSourcesInterface;
use Symfony\AI\Agent\Toolbox\Source\SourceCollection;
use Symfony\AI\Agent\Toolbox\ToolFactory\ReflectionToolFactory;
use Symfony\AI\Platform\Result\ToolCall;
use Symfony\AI\Platform\Tool\Tool;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

/**
 * @author Christopher Hertel <mail@christopher-hertel.de>
 */
final class Toolbox implements ToolboxInterface
{
    /**
     * List of tool metadata objects.
     *
     * @var Tool[]
     */
    private array $toolsMetadata;

    /**
     * Maps tool name to the specific object instance that was registered for it.
     *
     * @var array<string, object>
     */
    private array $instanceMap = [];

    /**
     * @param iterable<object> $tools
     */
    public function __construct(
        private readonly iterable $tools,
        private readonly ToolFactoryInterface $toolFactory = new ReflectionToolFactory(),
        private readonly ToolCallArgumentResolverInterface $argumentResolver = new ToolCallArgumentResolver(),
        private readonly LoggerInterface $logger = new NullLogger(),
        private readonly ?EventDispatcherInterface $eventDispatcher = null,
    ) {
    }

    public function getTools(): array
    {
        if (isset($this->toolsMetadata)) {
            return $this->toolsMetadata;
        }

        $toolsMetadata = [];
        foreach ($this->tools as $tool) {
            foreach ($this->toolFactory->getTool($tool) as $metadata) {
                $this->instanceMap[$metadata->getName()] = $tool;
                $toolsMetadata[] = $metadata;
            }
        }

        return $this->toolsMetadata = $toolsMetadata;
    }

    public function execute(ToolCall $toolCall): ToolResult
    {
        $metadata = $this->getMetadata($toolCall);

        $event = new ToolCallRequested($toolCall, $metadata);
        $this->eventDispatcher?->dispatch($event);

        if ($event->isDenied()) {
            $this->logger->debug(\sprintf('Tool "%s" denied: %s', $toolCall->getName(), $event->getDenialReason()));

            return new ToolResult($toolCall, $event->getDenialReason() ?? 'Tool execution denied.');
        }

        if ($event->hasResult()) {
            return $event->getResult();
        }

        $tool = $this->getExecutable($metadata);

        try {
            $this->logger->debug(\sprintf('Executing tool "%s".', $toolCall->getName()), $toolCall->getArguments());

            $arguments = $this->argumentResolver->resolveArguments($metadata, $toolCall);
            $this->eventDispatcher?->dispatch(new ToolCallArgumentsResolved($tool, $metadata, $arguments));

            if ($tool instanceof HasSourcesInterface) {
                $tool->setSourceCollection($sourceCollection = new SourceCollection());
            }

            $result = new ToolResult(
                $toolCall,
                $tool->{$metadata->getReference()->getMethod()}(...$arguments),
                $tool instanceof HasSourcesInterface ? $sourceCollection : null,
            );

            $this->eventDispatcher?->dispatch(new ToolCallSucceeded($tool, $metadata, $arguments, $result));
        } catch (ToolExecutionExceptionInterface $e) {
            $this->eventDispatcher?->dispatch(new ToolCallFailed($tool, $metadata, $arguments ?? [], $e));
            throw $e;
        } catch (\Throwable $e) {
            $this->logger->warning(\sprintf('Failed to execute tool "%s".', $toolCall->getName()), ['exception' => $e]);
            $this->eventDispatcher?->dispatch(new ToolCallFailed($tool, $metadata, $arguments ?? [], $e));
            throw ToolExecutionException::executionFailed($toolCall, $e);
        }

        return $result;
    }

    private function getMetadata(ToolCall $toolCall): Tool
    {
        foreach ($this->getTools() as $metadata) {
            if ($metadata->getName() === $toolCall->getName()) {
                return $metadata;
            }
        }

        throw ToolNotFoundException::notFoundForToolCall($toolCall);
    }

    private function getExecutable(Tool $metadata): object
    {
        if (isset($this->instanceMap[$metadata->getName()])) {
            return $this->instanceMap[$metadata->getName()];
        }

        $className = $metadata->getReference()->getClass();
        foreach ($this->tools as $tool) {
            if ($tool instanceof $className) {
                return $tool;
            }
        }

        throw ToolNotFoundException::notFoundForReference($metadata->getReference());
    }
}
