<?php

namespace App\Agent;

use App\Models\ArtifactFieldSource;
use App\Models\ConversationArtifact;
use App\Models\ConversationState;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;

/**
 * The conversation a turn is happening in.
 *
 * Resolved once per request and handed to every tool, so a tool never has to
 * be told which campaign or landing page the user means. Tools call focus()
 * to read it and produced() to record what they made.
 *
 * laravel/ai owns the transcript. This owns everything around it.
 */
class Workspace
{
    private ?ConversationState $state = null;

    /** @var (\Closure(): ?string)|null resolves the id once the agent has one */
    private $resolver = null;

    /**
     * Focus and artifacts recorded before a conversation existed.
     *
     * The agent creates its conversation only once the first turn completes,
     * but tools run during that turn. Buffering here and flushing on bind is
     * what lets the very first message create a campaign and then edit it,
     * rather than each tool finding nothing in focus and the model retrying.
     */
    private ?Model $pendingFocus = null;

    /** @var list<array{0: Model, 1: ?string}> */
    private array $pendingArtifacts = [];

    public function __construct(private ?string $conversationId = null) {}

    /**
     * Let the workspace find its own conversation id.
     *
     * The agent creates a conversation lazily, part way through the first turn:
     * instructions are built before it exists, but tools run after. Without
     * this, a tool firing on that first turn would write its artifact against
     * no conversation and be silently orphaned.
     *
     * @param  \Closure(): ?string  $resolver
     */
    public function resolveIdUsing(\Closure $resolver): void
    {
        $this->resolver = $resolver;
    }

    public function bindTo(?string $conversationId): void
    {
        // An empty id is not a conversation. The agent asks for instructions
        // before the first message is stored, so there is a real window where
        // no conversation exists yet and the workspace must stay inert.
        $this->conversationId = filled($conversationId) ? $conversationId : null;
        $this->state = null;
    }

    public function id(): ?string
    {
        if ($this->conversationId === null && $this->resolver !== null) {
            $resolved = ($this->resolver)();

            if (filled($resolved)) {
                $this->conversationId = $resolved;
                $this->flush();
            }
        }

        return $this->conversationId;
    }

    public function isBound(): bool
    {
        return $this->id() !== null;
    }

    // ------------------------------------------------------------------ focus

    /**
     * What the user is currently talking about, if anything.
     *
     * Once a conversation exists the stored row is the truth, read fresh: an
     * in-memory instance would hand a later tool a copy that predates whatever
     * an earlier one wrote. The buffered value is only a stand-in for the
     * window before the conversation exists.
     */
    public function focus(): ?Model
    {
        return $this->isBound() ? $this->state()?->focus : $this->pendingFocus;
    }

    /** @param  class-string<Model>  $type */
    public function focusIs(string $type): bool
    {
        return $this->focus() instanceof $type;
    }

    /**
     * Point the conversation at something. Called whenever a tool creates an
     * artifact, so "set the budget to $30" lands on the thing just made rather
     * than on whatever was in focus before.
     */
    public function focusOn(Model $artifact): void
    {
        if (! $this->isBound()) {
            // No conversation yet. Hold it until there is one.
            $this->pendingFocus = $artifact;

            return;
        }

        $this->pendingFocus = null;

        $this->state()->forceFill([
            'focus_type' => $artifact->getMorphClass(),
            'focus_id' => $artifact->getKey(),
        ])->save();

        // Drop the loaded relation rather than caching the instance passed in.
        // Two failure modes to avoid: a relation read as null before this call
        // would leave tools acting on nothing straight after a create, and
        // caching this instance would hand later tools a copy that predates
        // whatever another tool has since written.
        $this->state()->unsetRelation('focus');
    }

    public function clearFocus(): void
    {
        $this->pendingFocus = null;

        if (! $this->isBound()) {
            return;
        }

        $this->state()->forceFill(['focus_type' => null, 'focus_id' => null])->save();
        $this->state()->setRelation('focus', null);
    }

    // -------------------------------------------------------------- artifacts

    /**
     * Record that this conversation produced something, and focus it.
     *
     * The link is idempotent so a retried tool call does not create duplicates.
     */
    public function produced(Model $artifact, ?string $messageId = null): void
    {
        if (! $this->isBound()) {
            $this->pendingArtifacts[] = [$artifact, $messageId];
            $this->focusOn($artifact);

            return;
        }

        ConversationArtifact::query()->updateOrCreate(
            [
                'conversation_id' => $this->id(),
                'artifactable_type' => $artifact->getMorphClass(),
                'artifactable_id' => $artifact->getKey(),
            ],
            ['created_by_message_id' => $messageId],
        );

        $this->focusOn($artifact);
    }

    /**
     * Everything this conversation has produced, newest first.
     *
     * @return Collection<int, ConversationArtifact>
     */
    public function artifacts(?string $type = null): Collection
    {
        if (! $this->isBound()) {
            return $this->bufferedArtifacts($type);
        }

        return ConversationArtifact::query()
            ->with('artifactable')
            ->where('conversation_id', $this->id())
            ->when($type, fn ($query) => $query->where('artifactable_type', $type))
            ->latest('id')
            ->get();
    }

    // ------------------------------------------------------------- provenance

    /** Record where a field's value came from. */
    public function recordSource(Model $artifact, string $field, string $source): void
    {
        ArtifactFieldSource::query()->updateOrCreate(
            [
                'artifactable_type' => $artifact->getMorphClass(),
                'artifactable_id' => $artifact->getKey(),
                'field' => $field,
            ],
            ['source' => $source],
        );
    }

    /**
     * Field name to source, for everything recorded against an artifact.
     *
     * @return array<string,string>
     */
    public function sourcesFor(Model $artifact): array
    {
        return ArtifactFieldSource::query()
            ->where('artifactable_type', $artifact->getMorphClass())
            ->where('artifactable_id', $artifact->getKey())
            ->pluck('source', 'field')
            ->all();
    }

    /**
     * Drop recorded sources for fields whose values no longer apply.
     *
     * Used when a change invalidates dependent values: a Page id verified
     * against one ad account means nothing on another, and leaving its
     * provenance behind would let a stale value pass the publish gate.
     */
    public function forgetSources(Model $artifact, string ...$fields): void
    {
        ArtifactFieldSource::query()
            ->where('artifactable_type', $artifact->getMorphClass())
            ->where('artifactable_id', $artifact->getKey())
            ->whereIn('field', $fields)
            ->delete();
    }

    /**
     * Fields that were guessed and still need a human to confirm them.
     *
     * @return list<string>
     */
    public function unconfirmed(Model $artifact): array
    {
        return collect($this->sourcesFor($artifact))
            ->filter(fn (string $source): bool => $source === ArtifactFieldSource::FALLBACK)
            ->keys()
            ->all();
    }

    // ------------------------------------------------------------------ owner

    public function owner(): ?Model
    {
        return $this->state()?->owner;
    }

    public function ownedBy(Model $owner): void
    {
        if (! $this->isBound()) {
            return;
        }

        $this->state()->forceFill([
            'owner_type' => $owner->getMorphClass(),
            'owner_id' => $owner->getKey(),
        ])->save();

        $this->state()->unsetRelation('owner');
    }

    // -------------------------------------------------------------- internals

    /**
     * The stored state row, created on first use.
     *
     * Null while unbound rather than throwing: the agent reads focus when
     * building its instructions, which happens before the first message has
     * created a conversation, and that is a normal state rather than an error.
     */
    /** Write anything buffered before the conversation existed. */
    private function flush(): void
    {
        foreach ($this->pendingArtifacts as [$artifact, $messageId]) {
            $this->produced($artifact, $messageId);
        }

        $this->pendingArtifacts = [];

        if ($this->pendingFocus) {
            $this->focusOn($this->pendingFocus);
        }
    }

    /**
     * Buffered artifacts shaped like stored ones, so callers do not have to
     * care whether a conversation exists yet.
     *
     * @return Collection<int, ConversationArtifact>
     */
    private function bufferedArtifacts(?string $type): Collection
    {
        return collect($this->pendingArtifacts)
            ->map(fn (array $pending): Model => $pending[0])
            ->when($type, fn (Collection $all) => $all->filter(
                fn (Model $artifact): bool => $artifact->getMorphClass() === $type,
            ))
            ->map(fn (Model $artifact): ConversationArtifact => tap(
                new ConversationArtifact([
                    'conversation_id' => null,
                    'artifactable_type' => $artifact->getMorphClass(),
                    'artifactable_id' => $artifact->getKey(),
                ]),
                fn (ConversationArtifact $link) => $link->setRelation('artifactable', $artifact),
            ))
            ->values();
    }

    private function state(): ?ConversationState
    {
        if (! $this->isBound()) {
            return null;
        }

        return $this->state ??= ConversationState::query()->firstOrCreate([
            'conversation_id' => $this->id(),
        ]);
    }
}
