<?php

namespace App\Agent;

use Laravel\Ai\Approvals\Decisions;
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Contracts\RemembersConversations as RemembersConversationsContract;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
use Laravel\Ai\Responses\AgentResponse;
use Laravel\Ai\Responses\StreamableAgentResponse;
use Stringable;

/**
 * The one agent the product talks to.
 *
 * It holds no domain knowledge. Tools come from the registry, which each module
 * fills from its own service provider, and state comes from the workspace. Add
 * a capability by registering a tool, never by editing this class.
 *
 * laravel/ai owns the transcript and the tool-calling loop. Two behaviours here
 * are load-bearing:
 *
 *   - instructions() is rebuilt per generation, so the model is re-shown current
 *     state every turn and cannot drift from what is stored.
 *   - binding the workspace to the conversation id is what lets a tool know
 *     which campaign "set the budget to $30" refers to.
 */
class MarketingAgent implements Agent, HasTools, RemembersConversationsContract
{
    // Promptable is a trait, so the wrapped turn is reached by alias rather
    // than by parent::.
    use Promptable {
        Promptable::prompt as protected runTurn;
        Promptable::stream as protected runStreamTurn;
    }
    use RemembersConversations;

    public function __construct(
        private readonly ToolRegistry $registry,
        private readonly Workspace $workspace,
        private readonly Instructions $instructions,
    ) {
        $this->installResolver();
    }

    /**
     * forParticipant() and continue() return a clone, and a closure bound in the
     * constructor keeps pointing at the original, whose conversation is always
     * null. Re-binding on clone is what makes the resolver follow the instance
     * that actually has the conversation.
     */
    public function __clone(): void
    {
        $this->installResolver();
    }

    /**
     * The conversation does not exist when instructions are first built, but it
     * does by the time a tool runs. Handing the workspace a resolver rather than
     * an id closes that window: without it, anything created on the very first
     * turn is written against no conversation and silently orphaned.
     */
    private function installResolver(): void
    {
        $this->workspace->resolveIdUsing(fn (): ?string => $this->currentConversation());
    }

    /**
     * Run a turn, then make sure nothing produced during it is left in limbo.
     *
     * The package creates the conversation in a callback that fires only after
     * the whole run finishes, so on a first turn every tool executes with no
     * conversation to belong to. The workspace buffers through that window;
     * touching id() here is what resolves it and writes the buffer down.
     * Without this the first campaign of every chat is created but orphaned,
     * and the next turn finds nothing in focus.
     */
    public function prompt(
        Decisions|string $prompt,
        array $attachments = [],
        Lab|array|string|null $provider = null,
        ?string $model = null,
        ?int $timeout = null,
    ): AgentResponse {
        return tap(
            $this->runTurn($prompt, $attachments, $provider, $model, $timeout),
            fn () => $this->workspace->id(),
        );
    }

    public function stream(
        Decisions|string $prompt,
        array $attachments = [],
        Lab|array|string|null $provider = null,
        ?string $model = null,
        ?int $timeout = null,
    ): StreamableAgentResponse {
        return tap(
            $this->runStreamTurn($prompt, $attachments, $provider, $model, $timeout),
            fn (StreamableAgentResponse $response) => $response->then(fn () => $this->workspace->id()),
        );
    }

    public function instructions(): Stringable|string
    {
        $this->syncWorkspace();

        return $this->instructions->build();
    }

    /** @return list<Tool> */
    public function tools(): iterable
    {
        $this->syncWorkspace();

        return $this->registry->resolve();
    }

    public function workspace(): Workspace
    {
        return $this->workspace;
    }

    /**
     * Keep the workspace pointed at whichever conversation is running.
     *
     * Continuing an older conversation replaces the id outright; the resolver
     * only fills a blank one, so a rebind still has to be explicit.
     */
    private function syncWorkspace(): void
    {
        $conversationId = $this->currentConversation();

        if ($conversationId && $conversationId !== $this->workspace->id()) {
            $this->workspace->bindTo($conversationId);
        }
    }
}
