<?php
namespace Nibiru\Module\Ai\Plugins;

/**
 * Tool-using agent. Runs an LLM in a ReAct-style loop: it reads the
 * user's task, picks a tool, runs it, observes the result, repeats —
 * until it emits a final answer.
 *
 *   $agent = $ai->agent()->withTools([
 *       new Plugin\Tools\PdoQuery(),
 *       new Plugin\Tools\HttpGet(),
 *       new Plugin\Tools\FileRead(),
 *   ]);
 *   echo $agent->run('How many active users do we have?');
 *
 * Tool-call protocol uses a simple JSON sentinel — no model-specific
 * tool-calling APIs needed, so it works on every Ollama model. Models that
 * support native tool-calling can be plugged in via a subclass override
 * of {@link parseToolCall()}.
 */
class Agent
{
    protected \stdClass $cfg;
    protected Chat $chat;

    /** @var Tool[] */
    protected array $tools = [];

    /** @var array<int, array{step:int, action:string, observation:string}> */
    protected array $trace = [];

    public function __construct(\stdClass $cfg, Chat $chat)
    {
        $this->cfg = $cfg;
        $this->chat = $chat;
    }

    /** @param Tool[] $tools */
    public function withTools(array $tools): self
    {
        $this->tools = $tools;
        return $this;
    }

    /**
     * Run the agent against a task. Returns the final answer text.
     */
    public function run(string $task): string
    {
        $maxIter = (int) ($this->cfg->agent_max_iterations ?? 6);
        $this->trace = [];

        $system = $this->systemPrompt();
        $this->chat->reset()->system($system)->user($task);

        for ($step = 1; $step <= $maxIter; $step++) {
            $reply = $this->chat->complete();
            $call  = $this->parseToolCall($reply);

            if ($call === null) {
                // Final answer — no tool call detected.
                return $this->stripFinalMarker($reply);
            }

            $tool = $this->findTool($call['tool']);
            if ($tool === null) {
                $obs = "ERROR: no tool named \"{$call['tool']}\". Available: "
                     . implode(', ', array_map(fn($t) => $t->name(), $this->tools));
            } else {
                try {
                    $obs = (string) $tool->execute($call['args']);
                } catch (\Throwable $e) {
                    $obs = 'ERROR: ' . $e->getMessage();
                }
            }

            $this->trace[] = ['step' => $step, 'action' => json_encode($call), 'observation' => $obs];

            // Feed the observation back into the conversation.
            $this->chat->user("Observation:\n" . $obs . "\n\nContinue.");
        }

        return $this->chat->complete(); // final attempt after max iterations
    }

    /**
     * @return array<int, array{step:int, action:string, observation:string}>
     */
    public function trace(): array
    {
        return $this->trace;
    }

    // -----------------------------------------------------------------------
    // Internals
    // -----------------------------------------------------------------------

    protected function systemPrompt(): string
    {
        $toolBlock = '';
        foreach ($this->tools as $t) {
            $toolBlock .= $t->asPrompt() . "\n\n";
        }

        return <<<PROMPT
You are a Nibiru AI agent. You answer the user's task by either calling a tool
or producing a final answer.

When you need a tool, output a single fenced JSON block on its own line:

```tool
{"tool": "<name>", "args": { ... }}
```

After you receive the observation, decide whether to call another tool or to
produce a final answer. When done, write the final answer prefixed with
"FINAL:" on its own paragraph. Do not call a tool and produce a final answer
in the same turn.

Available tools:

$toolBlock
Be concise. Prefer real tool data over guesses. If a tool errors twice, give a
final answer that says you couldn't find out, and why.
PROMPT;
    }

    /**
     * Look for a ```tool { ... }``` JSON block. Returns null if absent.
     *
     * @return array{tool:string,args:array}|null
     */
    protected function parseToolCall(string $reply): ?array
    {
        if (preg_match('/```(?:tool|json)\s*\n(.*?)\n```/s', $reply, $m)) {
            $decoded = json_decode($m[1], true);
            if (is_array($decoded) && isset($decoded['tool'])) {
                return [
                    'tool' => (string) $decoded['tool'],
                    'args' => is_array($decoded['args'] ?? null) ? $decoded['args'] : [],
                ];
            }
        }
        // Fallback: explicit final marker
        if (preg_match('/^FINAL:\s*/m', $reply)) return null;
        // No tool call detected — treat as final.
        return null;
    }

    protected function stripFinalMarker(string $reply): string
    {
        return preg_replace('/^FINAL:\s*/m', '', $reply) ?? $reply;
    }

    protected function findTool(string $name): ?Tool
    {
        foreach ($this->tools as $t) {
            if ($t->name() === $name) return $t;
        }
        return null;
    }
}
