<?php

declare(strict_types=1);

namespace NeuronAI\Tools;

use NeuronAI\Exceptions\MissingCallbackParameter;
use NeuronAI\Exceptions\ToolCallableNotSet;
use NeuronAI\StaticConstructor;
use NeuronAI\StructuredOutput\Deserializer\Deserializer;
use NeuronAI\StructuredOutput\Deserializer\DeserializerException;
use ReflectionException;
use stdClass;

use function array_key_exists;
use function array_map;
use function array_reduce;
use function call_user_func;
use function is_array;
use function is_callable;
use function json_encode;
use function method_exists;

/**
 * @method static static make(?string $name = null, ?string $description = null, array $properties = [], array $parameters = [], array $annotations = [])
 */
class Tool implements ToolInterface
{
    use StaticConstructor;

    /**
     * @var null|callable
     */
    protected $callback;

    /**
     * The arguments to pass in to the callback.
     */
    protected array $inputs = [];

    /**
     * The call ID generated by the LLM.
     */
    protected ?string $callId = null;

    /**
     * The result of the execution.
     */
    protected string|null $result = null;

    /**
     * Define the maximum number of calls for the tool in a single agent session.
     */
    protected ?int $maxRuns = null;

    protected bool $visible = true;

    /**
     * Tool constructor.
     *
     * @param ToolPropertyInterface[] $properties
     * @params array<int, mixed>|null $parameters
     * @params array<int, mixed>|null $annotations
     */
    public function __construct(
        protected string $name,
        protected ?string $description = null,
        protected array $properties = [],
        protected array $parameters = [],
        protected array $annotations = []
    ) {
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function setName(string $name): ToolInterface
    {
        $this->name = $name;
        return $this;
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    public function setDescription(?string $description): ToolInterface
    {
        $this->description = $description;
        return $this;
    }

    public function addProperty(ToolPropertyInterface $property): ToolInterface
    {
        $this->properties[] = $property;
        return $this;
    }

    /**
     * @return ToolPropertyInterface[]
     */
    protected function properties(): array
    {
        return [];
    }

    /**
     * @return ToolPropertyInterface[]
     */
    public function getProperties(): array
    {
        if ($this->properties === []) {
            foreach ($this->properties() as $property) {
                $this->addProperty($property);
            }
        }

        return $this->properties;
    }

    public function getRequiredProperties(): array
    {
        return array_reduce($this->getProperties(), function (array $carry, ToolPropertyInterface $property): array {
            if ($property->isRequired()) {
                $carry[] = $property->getName();
            }

            return $carry;
        }, []);
    }

    public function getAnnotations(): array
    {
        return $this->annotations;
    }

    public function setParameters(array $parameters): self
    {
        $this->parameters = $parameters;
        return $this;
    }

    public function getParameters(): array
    {
        return $this->parameters;
    }

    public function getInputs(): array
    {
        return $this->inputs;
    }

    public function getInput(string $key): mixed
    {
        return $this->inputs[$key] ?? null;
    }

    public function setInputs(?array $inputs): self
    {
        $this->inputs = $inputs ?? [];
        return $this;
    }

    public function getCallId(): ?string
    {
        return $this->callId;
    }

    public function setCallId(?string $callId): self
    {
        $this->callId = $callId;
        return $this;
    }

    public function getResult(): string
    {
        return $this->result;
    }

    /**
     * todo: Add to the underlying ToolInterface in v4
     */
    public function setResult(mixed $result): self
    {
        $this->result = is_array($result) ? json_encode($result) : (string) $result;

        return $this;
    }

    public function getMaxRuns(): ?int
    {
        return $this->maxRuns;
    }

    public function setMaxRuns(int $tries): self
    {
        $this->maxRuns = $tries;
        return $this;
    }

    /**
     * @deprecated Use setMaxRuns instead.
     */
    public function setMaxTries(int $tries): self
    {
        $this->maxRuns = $tries;
        return $this;
    }

    public function visible(bool $visible): ToolInterface
    {
        $this->visible = $visible;
        return $this;
    }

    public function isVisible(): bool
    {
        return $this->visible;
    }

    public function setCallable(callable $callback): self
    {
        $this->callback = $callback;
        return $this;
    }

    /**
     * Execute the client side function.
     *
     * @throws MissingCallbackParameter
     * @throws ToolCallableNotSet
     * @throws DeserializerException
     * @throws ReflectionException
     */
    public function execute(): void
    {
        if (!is_callable($this->callback) && !method_exists($this, '__invoke')) {
            throw new ToolCallableNotSet('No function defined for tool execution.');
        }

        // Validate required parameters
        foreach ($this->getProperties() as $property) {
            if ($property->isRequired() && !array_key_exists($property->getName(), $this->getInputs())) {
                throw new MissingCallbackParameter("Missing required parameter: {$property->getName()}");
            }
        }

        $parameters = array_reduce($this->getProperties(), function (array $carry, ToolPropertyInterface $property): array {
            $propertyName = $property->getName();
            $inputs = $this->getInputs();

            // Normalize missing optional properties by assigning them a null value
            // Treat it as explicitly null to ensure a consistent structure
            if (!array_key_exists($propertyName, $inputs)) {
                $carry[$propertyName] = null;
                return $carry;
            }

            // Find the corresponding input value
            $inputValue = $inputs[$propertyName];

            // If there is an object property with a class definition,
            // deserialize the tool input into an instance of that class
            if ($property instanceof ObjectProperty && $property->getClass()) {
                $carry[$propertyName] = Deserializer::make()->fromJson(json_encode($inputValue), $property->getClass());
                return $carry;
            }

            // If a property is an array of objects and each item matches a class definition,
            // deserialize each item into an instance of that class
            if ($property instanceof ArrayProperty) {
                $items = $property->getItems();
                if ($items instanceof ObjectProperty && $items->getClass()) {
                    $class = $items->getClass();
                    $carry[$propertyName] = array_map(fn (array|object $input): object => Deserializer::make()->fromJson(json_encode($input), $class), $inputValue);
                    return $carry;
                }
            }

            // No extra treatments for basic property types
            $carry[$propertyName] = $inputValue;
            return $carry;

        }, []);

        if (is_callable($this->callback)) {
            $this->setResult(call_user_func($this->callback, ...$parameters));
        } elseif (method_exists($this, '__invoke')) {
            $this->setResult($this->__invoke(...$parameters));
        } else {
            throw new ToolCallableNotSet('No function defined for tool execution.');
        }
    }

    public function jsonSerialize(): array
    {
        return [
            'callId' => $this->callId,
            'name' => $this->name,
            'description' => $this->description,
            'parameters' => $this->parameters,
            'inputs' => $this->inputs === [] ? new stdClass() : $this->inputs,
            'result' => $this->result,
        ];
    }
}
