<?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;
use Illuminate\Support\Facades\DB;

/**
 * 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 = [];

    /** Set before the conversation exists on a first turn, written on flush. */
    private ?Model $pendingOwner = null;

    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;

        // Anything recorded before the id was known belongs to this
        // conversation. Without this, binding after a turn leaves the buffer
        // stranded and the conversation looks empty.
        if ($this->conversationId !== null) {
            $this->flush();
        }
    }

    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
    {
        if (! $this->isBound()) {
            return $this->pendingFocus;
        }

        // Reloaded rather than cached. Other code writes to the focused record
        // without going through here at all, the publisher most of all, and a
        // cached copy taken before that write is how a tool ends up reporting a
        // published campaign as still a draft. A query per call is cheaper than
        // being wrong about what is live.
        return $this->state()?->unsetRelation('focus')->focus;
    }

    /** @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.
     */
    /**
     * @param  bool  $focus  whether this becomes what the conversation is working on
     *
     * Creating something focuses it, which is what makes "make it thirty a day"
     * resolvable straight after. Bringing in something to be used by whatever is
     * already in focus is the opposite case: importing creatives for a campaign
     * moved focus onto the last creative, and the next tool then found no
     * campaign to attach it to.
     */
    public function produced(Model $artifact, ?string $messageId = null, bool $focus = true): void
    {
        if (! $this->isBound()) {
            $this->pendingArtifacts[] = [$artifact, $messageId];

            if ($focus) {
                $this->focusOn($artifact);
            }

            return;
        }

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

        if ($focus) {
            $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
    {
        if (! $this->isBound()) {
            return $this->pendingOwner;
        }

        return $this->state()?->owner ?? $this->pendingOwner;
    }

    /**
     * Who this conversation belongs to.
     *
     * Buffered while unbound, because the first message arrives before the
     * conversation exists and dropping it there left every conversation ownerless.
     */
    public function ownedBy(Model $owner): void
    {
        if (! $this->isBound()) {
            $this->pendingOwner = $owner;

            return;
        }

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

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

    // -------------------------------------------------------------- choices

    /**
     * Keep what was offered, so a reload does not lose the buttons.
     *
     * @param  list<array<string, mixed>>  $choices
     */
    public function rememberChoices(array $choices): void
    {
        if (! $this->isBound()) {
            return;
        }

        $this->state()->forceFill(['pending_choices' => $choices ?: null])->save();
    }

    /** @return list<array<string, mixed>> */
    public function pendingChoices(): array
    {
        return (array) ($this->state()?->pending_choices ?? []);
    }

    /** @param list<array<string, mixed>> $accounts */
    public function rememberGoogleAdsAccounts(array $accounts): void
    {
        if (! $this->isBound()) {
            return;
        }

        $this->state()->forceFill(['google_ads_accounts' => $accounts ?: null])->save();
    }

    /** @param array{customer_id: string, account_name: string} $account */
    public function rememberGoogleAdsAccount(array $account): void
    {
        if (! $this->isBound()) {
            return;
        }

        $this->state()->forceFill([
            'google_ads_account_id' => $account['customer_id'],
            'google_ads_account_name' => $account['account_name'],
        ])->save();
    }

    /** @return list<array<string, mixed>> */
    public function googleAdsAccounts(): array
    {
        return (array) ($this->state()?->google_ads_accounts ?? []);
    }

    /** @return array{customer_id: ?string, account_name: ?string} */
    public function selectedGoogleAdsAccount(): array
    {
        return [
            'customer_id' => $this->state()?->google_ads_account_id,
            'account_name' => $this->state()?->google_ads_account_name,
        ];
    }

    /**
     * Whether this field was put on screen for the user to answer last turn.
     *
     * The honest difference between a value the user chose and one the model
     * decided for them. A tool cannot tell those apart from its arguments: the
     * model passes a number either way. What it can tell is whether anybody was
     * asked, and a value arriving for a question that was never on screen is
     * the model's, not the user's.
     *
     * Deterministic on purpose. The alternative was asking the model to declare
     * which it was, which is the same model whose invented budget is the reason
     * this exists.
     */
    public function wasOffered(string $field): bool
    {
        foreach ($this->pendingChoices() as $choice) {
            if (($choice['field'] ?? null) === $field) {
                return true;
            }
        }

        return false;
    }

    // --------------------------------------------------------- what was said

    /**
     * What the user has typed in this conversation, newest first.
     *
     * laravel/ai owns the transcript and this class owns everything around it,
     * so reading it here is a deliberate exception rather than a habit. It
     * exists because the first thing somebody says is often the only clue to
     * which brand they are buying for, and the step that needs that clue, the
     * ad account, comes before any field has been filled in. Matching on the
     * landing page instead meant the suggestion could never fire: the URL is
     * asked for three steps later.
     *
     * @return list<string>
     */
    public function saidByUser(int $limit = 5): array
    {
        if (! $this->isBound()) {
            return [];
        }

        return DB::table('agent_conversation_messages')
            ->where('conversation_id', $this->id())
            ->where('role', 'user')
            ->orderByDesc('created_at')
            ->orderByDesc('id')
            ->limit($limit)
            ->pluck('content')
            ->filter()
            ->map(fn ($content): string => (string) $content)
            ->values()
            ->all();
    }

    // -------------------------------------------------------------- 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
    {
        // focus: false, deliberately. Replaying these through produced() with
        // its default re-focused every artifact as it was written, so the last
        // one buffered won and the caller's choice was silently overruled:
        // starting on Meta and Google put Meta in focus, and flushing handed it
        // to Google, which is how the chat came to ask Meta's questions beside
        // a panel headed google.
        //
        // What was intended is already held in $this->pendingFocus, recorded at
        // the moment the caller asked for it, and applied below.
        foreach ($this->pendingArtifacts as [$artifact, $messageId]) {
            $this->produced($artifact, $messageId, focus: false);
        }

        $this->pendingArtifacts = [];

        if ($this->pendingOwner) {
            $this->ownedBy($this->pendingOwner);
            $this->pendingOwner = null;
        }

        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
    {
        // Newest first, the same order artifacts() returns once a conversation
        // exists. They disagreed, so latestCampaign()->first() meant the oldest
        // during the first turn and the newest from the second on.
        return collect($this->pendingArtifacts)
            ->reverse()
            ->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(),
        ]);
    }
}
