<?php

namespace App\Http\Controllers;

use App\Agent\Answers;
use App\Agent\Choices;
use App\Agent\MarketingAgent;
use App\Agent\ToolRegistry;
use App\Agent\Workspace;
use App\Assets\AssetLibrary;
use App\Campaigns\Briefs;
use App\Campaigns\LaunchFlow;
use App\Campaigns\PendingChanges;
use App\Campaigns\Platforms\Platforms;
use App\Campaigns\Publisher;
use App\Campaigns\PublishGate;
use App\Models\ApiCall;
use App\Models\Asset;
use App\Models\Campaign;
use App\Support\Actor;
use App\Support\Markdown;
use App\Support\ProviderFailure;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Laravel\Ai\Approvals\Decision;
use Laravel\Ai\Approvals\Decisions;
use Laravel\Ai\Exceptions\NoSuchToolException;
use Laravel\Ai\Models\Conversation;
use Laravel\Ai\Streaming\Events\TextDelta;
use Laravel\Ai\Tools\Request as ToolRequest;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;

/**
 * The chat.
 *
 * Thin on purpose: it moves a message into the agent and renders whatever came
 * back. Every decision about what the campaign becomes lives in a tool, and
 * every decision about whether it may be published lives in the gate.
 */
class ChatController extends Controller
{
    /** Attempts at a turn, when the provider was only busy. */
    private const PROVIDER_ATTEMPTS = 3;

    /**
     * The step the controller had to put on screen itself, if any.
     *
     * Only ever set when the model offered nothing, which means it was talking
     * about something other than the step the flow is on.
     *
     * @var array<string, mixed>|null
     */
    private ?array $steppedIn = null;

    /** The newest assistant row before this turn ran, so its own row can be told apart. */
    private ?string $latestAssistantId = null;

    public function __construct(
        private readonly MarketingAgent $agent,
        private readonly Workspace $workspace,
        private readonly PublishGate $gate,
        private readonly Publisher $publisher,
        private readonly PendingChanges $pending,
        private readonly AssetLibrary $assets,
        private readonly Actor $actor,
        private readonly Choices $choices,
        private readonly LaunchFlow $flow,
        private readonly ToolRegistry $registry,
        private readonly Answers $answers,
        private readonly Platforms $platforms,
        private readonly Briefs $briefs,
    ) {}

    /**
     * List all chat conversations.
     */
    public function index(Request $request): View
    {
        // Scoped, like every other route that reads a conversation.
        //
        // This listed every conversation in the system, and so did both counts
        // below it, so any signed-in user could page through everyone's chats
        // and see how many there were. The page is behind auth, which is not
        // the same as being behind ownership.
        //
        // Built as one scoped query and reused, rather than three queries that
        // have to remember the same two conditions: that is how the counts came
        // to disagree with what the list could show.
        $mine = fn () => $this->readable($request)
            ->when(
                Schema::hasColumn('agent_conversations', 'chat_type'),
                fn ($q) => $q->whereNull('chat_type'),
            );

        $conversations = $mine()
            ->when($request->filled('q'), function ($q) use ($request) {
                $search = trim((string) $request->input('q'));
                $q->where('title', 'like', '%'.$search.'%');
            })
            ->latest('updated_at')
            ->paginate(15)
            ->withQueryString();

        return view('chats.index', [
            'conversationsList' => $conversations,
            'stats' => [
                'all' => $mine()->count(),
                'today' => $mine()->whereDate('updated_at', today())->count(),
            ],
        ]);
    }

    /**
     * Conversations this request is allowed to see.
     *
     * The same rule {@see mayOpen()} applies to opening one by URL, expressed
     * as a query so a list and a lookup can never drift apart. Scoping the
     * list while leaving the URL open, or the reverse, is the failure mode
     * worth designing against: the list hides what the link still reveals.
     */
    private function readable(Request $request): Builder
    {
        $query = Conversation::query();

        if (config('agent.shared_conversations')) {
            return $query;
        }

        $user = $request->user();

        if ($user === null) {
            // Not reachable behind auth middleware, and not worth trusting to
            // stay that way: an impossible condition is an empty list, which is
            // the safe answer, where an unfiltered query is everyone's.
            return $query->whereRaw('1 = 0');
        }

        return $query->where(fn ($scope) => $scope
            // A conversation with no participant recorded predates ownership
            // being tracked. mayOpen() lets those through, so the list has to
            // as well: hiding them here while the URL still opens them is the
            // drift this method exists to prevent.
            ->whereNull('participant_id')
            ->orWhere(fn ($mine) => $mine
                ->where('participant_type', $user->getMorphClass())
                ->where('participant_id', $user->getAuthIdentifier())));
    }

    public function show(Request $request): View
    {
        $conversation = $this->resolveConversation($request);

        if ($conversation) {
            $this->bindWorkspace($conversation->id, $request);
        }

        return view('chat', [
            'conversationId' => $conversation?->id,
            // The sidebar named the conversation and the header said "New chat"
            // beside it, on a conversation that had been going for twenty turns.
            'pageTitle' => $conversation ? $this->conversationTitle($conversation->id) : 'New chat',
            'transcript' => $this->transcript($conversation),
            // A run paused for approval lives on the server, but the prompt was
            // only ever drawn from a JSON response. Reloading stranded it: the
            // run stayed paused with nothing left on screen to resolve it.
            'awaiting' => $this->unresolvedApprovals($conversation),
            // Options offered on an earlier turn. Built only from the JSON
            // response before, so reloading lost the buttons while leaving the
            // question unanswered.
            'choices' => $conversation ? $this->workspace->pendingChoices() : [],
            'conversations' => $this->recentConversations($request),
            ...$this->panel(),
        ]);
    }

    /**
     * A blank chat.
     *
     * Nothing is created here. A conversation only exists once there is
     * something in it, so this asks the screen for an empty one and the first
     * message brings it into being.
     *
     * The flag matters: without it this lands on /chat, which opens the most
     * recent conversation, and "New chat" reopens the one you were just in.
     */
    public function create(): RedirectResponse
    {
        return to_route('chat.show', ['new' => 1]);
    }

    /** One message through the agent, with any files the user attached. */
    public function send(Request $request): JsonResponse|StreamedResponse
    {
        $validated = $request->validate([
            'message' => ['nullable', 'string', 'max:4000'],
            'conversation' => ['nullable', 'string', 'max:36'],
            'files' => ['nullable', 'array', 'max:5'],
            'files.*' => ['file', 'mimes:jpg,jpeg,png,gif,mp4,mov', 'max:51200'],
            // What the user clicked, as the value the platform accepts rather
            // than as a sentence about it.
            'answers' => ['nullable', 'array', 'max:8'],
            'answers.*.field' => ['required', 'string', 'max:64'],
            'answers.*.value' => ['present'],
        ]);

        $agent = $validated['conversation'] ?? null
            ? $this->agent->continue(
                $this->readableConversation($request, $validated['conversation'])->id,
                $request->user(),
            )
            : $this->agent->forParticipant($request->user());

        // Attachments become assets before the turn runs, so the agent's very
        // first look at the workspace already sees them and does not ask the
        // user to upload what they just uploaded.
        $this->bindWorkspace($agent->currentConversation(), $request);

        // An answer to a question we put on screen is applied here rather than
        // left to the model to read back out of a sentence. It still sees the
        // message and still replies; it just no longer decides whether the
        // answer was recorded. Anything unrecognised falls through untouched.
        $this->applyAnswers($validated['answers'] ?? []);

        foreach ($request->file('files', []) as $file) {
            $this->workspace->produced($this->attach($file, $request->user()));
        }

        // Attaching focuses the asset; the campaign is what the user is talking
        // about, so put it back if there is one.
        //
        // Only when a campaign is not already focused. This ran on every turn
        // and focused latestCampaign(), which is the newest campaign artifact
        // rather than the one in hand, so a brief with three platforms snapped
        // back to the last one created after every single message. Driving
        // arb-dev on 25 Sep it moved to Meta, answered one question there, and
        // was on LinkedIn again by the next turn; the product said so itself:
        // "The declaration failed because focus had reset to LinkedIn." Two
        // turns were spent re-answering the same question and an ad copy
        // request landed on the wrong campaign.
        //
        // A file attachment still hands focus to the asset, which is the case
        // the put-back is for, and that is exactly when focusIs() is false.
        if (! $this->workspace->focusIs(Campaign::class) && ($campaign = $this->latestCampaign())) {
            $this->workspace->focusOn($campaign);
        }

        $awaiting = [];
        $failed = false;

        $message = $this->promptFor($validated, $request);

        // Read before the turn so a failure can say whether anything was
        // applied before it, rather than asserting that nothing was.
        $before = $this->focusedCampaign()?->updated_at;
        $untouched = $this->footprint();

        // And the last reply already on record, so the row this turn writes can
        // be told from the one before it. A turn can store nothing at all, and
        // without this the displayed reply would be written over the previous
        // turn's words.
        $this->latestAssistantId = $this->latestAssistantMessageId($agent->currentConversation());

        if ($this->shouldStream($request)) {
            return $this->streamTurn($agent, $request, $message, $before, $untouched);
        }

        try {
            $response = $this->promptWithRetry($agent, $message, $untouched);
            $reply = trim((string) $response->text);
            $awaiting = $this->awaiting($response);
        } catch (Throwable $e) {
            report($e);
            $failed = true;

            // Never dead-end. Say what broke, in words, and say honestly how
            // far it got.
            $reply = 'That step failed: '.$this->readableFailure($e)."\n\n".$this->howFarItGot($before);

            // The package writes the transcript in a callback that only runs on
            // success, so a failed turn leaves no trace: the user sees the error,
            // reloads, and both their message and the failure are gone. Record
            // it here so the history matches what actually happened.
            $this->recordFailedTurn($agent->currentConversation(), $request->user(), $message, $reply);
        }

        return response()->json($this->finalizeTurn($agent, $request, $message, $reply, $awaiting, $failed));
    }

    /**
     * Record what the user clicked, before the model gets a say.
     *
     * Ordering matters: this runs after the workspace is bound, so the tools
     * see the right conversation, and before the turn, so the model is shown
     * state that already includes the answer and moves on rather than asking
     * again.
     *
     * @param  list<array{field: string, value: mixed}>  $answers
     */
    private function applyAnswers(array $answers): void
    {
        // Merged by owning tool rather than applied one by one: a tool given
        // half its arguments re-offers the other half, which put an answered
        // question back on screen in the same turn.
        $this->answers->applyAll($answers);
    }

    /**
     * Point the conversation at a different campaign.
     *
     * A conversation can hold several, and asking the agent to switch is a slow
     * way to do something the interface should just let you click.
     */
    /**
     * Show a campaign in the panel. Looking, not working on.
     *
     * This used to call focusOn(), so clicking a tab retargeted the whole
     * conversation: 25 of the 27 campaign tools act on the focus, and the next
     * sentence the buyer typed landed on whichever tab they had last clicked.
     * Shyam, 23 Sep: "a user isn't aware of the code design decision, he might
     * just click unknowingly that his click will affect the answer he is going
     * to enter next."
     *
     * So a click changes what is on screen and nothing else. What is worked on
     * follows what is said, which switch_focus already handles, and the panel
     * says which campaign that is whenever the two differ.
     *
     * Nothing is persisted. The view lasts until the next turn, which renders
     * the focused campaign again, so after acting the buyer sees what they
     * acted on.
     */
    public function view(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'conversation' => ['required', 'string', 'max:36'],
            'campaign' => ['required', 'integer'],
        ]);

        $conversation = $this->readableConversation($request, $validated['conversation']);
        $this->bindWorkspace($conversation->id, $request);

        // Cast, because the request carries "14" and the `integer` rule
        // validates the shape without changing the type. Comparing that against
        // a cast id with === was false for every campaign, so switching tabs
        // answered 404 and the panel never moved off the first campaign.
        $wanted = (int) $validated['campaign'];

        $artifact = $this->workspace->artifacts(Campaign::class)
            ->first(fn ($link): bool => (int) $link->artifactable_id === $wanted);

        abort_unless($artifact?->artifactable !== null, 404);

        return response()->json(['ok' => true, ...$this->panel($artifact->artifactable)]);
    }

    /**
     * Resume a run the agent paused for approval.
     *
     * This is the human half of publishing from the chat: the model asked, the
     * run stopped before the tool executed, and nothing happens until this
     * arrives. A rejection is handed back to the model as a result so it can
     * respond, rather than being swallowed.
     */
    public function decide(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'conversation' => ['required', 'string', 'max:36'],
            'tool_call_id' => ['required', 'string', 'max:120'],
            'approved' => ['required', 'boolean'],
        ]);

        $conversation = $this->readableConversation($request, $validated['conversation']);

        $agent = $this->agent->continue($conversation->id, $request->user());
        $this->bindWorkspace($agent->currentConversation(), $request);

        $decision = $validated['approved']
            ? Decision::approve()
            // Worded for the model, which sees a rejection as a failed tool call
            // and otherwise reports it as the platform refusing. Nothing was
            // sent, so saying so is the whole point.
            : Decision::reject('Not a failure. The user declined this step in the chat, so nothing was sent to the platform and nothing changed. Say so plainly and ask what they want changed.');

        try {
            $response = $agent->prompt(Decisions::from([$validated['tool_call_id'] => $decision]));
            $reply = trim((string) $response->text);
            $awaiting = $this->awaiting($response);
        } catch (Throwable $e) {
            report($e);

            return response()->json([
                'ok' => false,
                'reply' => "That step failed: {$e->getMessage()}",
                'awaiting_approval' => [],
                ...$this->panel(),
            ], 502);
        }

        $reply = $reply !== '' ? $reply : $this->fallbackText($awaiting);

        return response()->json([
            'ok' => true,
            'choices' => $this->rememberedChoices(),
            'conversation' => $agent->currentConversation(),
            'reply' => $reply,
            'replyHtml' => (string) Markdown::toHtml($reply),
            'awaiting_approval' => $awaiting,
            ...$this->panel(),
        ]);
    }

    /**
     * Write a turn the provider never completed.
     *
     * Only reached when the run threw, which is exactly when the package's own
     * store does nothing. Written directly rather than through the store
     * contract because that expects a response object there is no response for.
     *
     * A first turn that fails has no conversation to belong to and is skipped:
     * inventing one would leave an empty chat in the sidebar.
     */
    private function recordFailedTurn(?string $conversationId, $user, string $message, string $reply): void
    {
        if (blank($conversationId)) {
            return;
        }

        $base = [
            'conversation_id' => $conversationId,
            'participant_type' => $user?->getMorphClass(),
            'participant_id' => $user?->getKey(),
            'agent' => MarketingAgent::class,
            'attachments' => '[]',
            'tool_calls' => '[]',
            'tool_results' => '[]',
            'usage' => '[]',
            'meta' => '[]',
            'created_at' => now(),
            'updated_at' => now(),
        ];

        DB::table('agent_conversation_messages')->insert([
            [...$base, 'id' => (string) Str::uuid7(), 'role' => 'user', 'content' => $message],
            [...$base, 'id' => (string) Str::uuid7(), 'role' => 'assistant', 'content' => $reply],
        ]);
    }

    /**
     * Store the words that were actually on screen.
     *
     * The package writes the assistant row with the model's raw text, and the
     * controller then builds the reply the browser is sent: an empty turn
     * becomes fallbackText(), and questionFor() may substitute the step's ask.
     * chat.js replaces the streamed bubble with that reply when `done`
     * arrives, and nothing wrote it back, so the transcript and the screen
     * disagreed from the moment the turn ended. Reloading showed a
     * conversation the user had never been shown, which is worse than either
     * version alone: two records of one exchange and no way to tell which one
     * the person was answering.
     *
     * Only the row this turn created is touched. A turn can legitimately store
     * nothing at all, and updating "the latest assistant row" without checking
     * would rewrite the previous turn's reply instead.
     */
    private function recordDisplayedReply(?string $conversationId, string $reply): void
    {
        // Null is a real value here, not a missing one: it means the
        // conversation had no assistant rows when the turn began, which is the
        // first message of a new chat and the one case that must not be
        // skipped. The comparison below is what decides.
        if (blank($conversationId) || $reply === '') {
            return;
        }

        $latest = $this->latestAssistantMessageId($conversationId);

        if ($latest === null || $latest === $this->latestAssistantId) {
            return;
        }

        DB::table('agent_conversation_messages')
            ->where('id', $latest)
            ->where('content', '!=', $reply)
            ->update(['content' => $reply, 'updated_at' => now()]);
    }

    /** The newest assistant row, by uuid7 order, which is creation order. */
    private function latestAssistantMessageId(?string $conversationId): ?string
    {
        if (blank($conversationId)) {
            return null;
        }

        return DB::table('agent_conversation_messages')
            ->where('conversation_id', $conversationId)
            ->where('role', 'assistant')
            ->orderByDesc('id')
            ->value('id');
    }

    /**
     * Approvals a reloaded page still needs to show.
     *
     * Only the newest assistant message can hold an unresolved pause: approving
     * or rejecting writes a further message, so anything older has been decided.
     *
     * @return list<array{id: string, tool: string, reason: ?string}>
     */
    private function unresolvedApprovals(?Conversation $conversation): array
    {
        if (! $conversation) {
            return [];
        }

        $last = DB::table('agent_conversation_messages')
            ->where('conversation_id', $conversation->id)
            ->where('role', 'assistant')
            ->orderByDesc('created_at')
            ->orderByDesc('id')
            ->first(['approval_state', 'tool_calls']);

        $pending = json_decode((string) ($last->approval_state ?? ''), true)['pending'] ?? [];

        if (! is_array($pending) || $pending === []) {
            return [];
        }

        // approval_state carries id and reason; the tool name lives alongside
        // it on the same row.
        $names = collect(json_decode((string) ($last->tool_calls ?? '[]'), true) ?: [])
            ->pluck('name', 'id');

        return collect($pending)
            ->map(fn (?string $reason, string $id): array => [
                'id' => $id,
                'tool' => str_replace('_', ' ', (string) $names->get($id, 'this action')),
                'reason' => $reason,
            ])
            ->values()
            ->all();
    }

    /**
     * Tool calls the run is paused on, if any.
     *
     * @return list<array{id: string, tool: string, reason: ?string}>
     */
    private function awaiting(?object $response): array
    {
        if ($response === null) {
            return [];
        }

        return collect($response->pendingApprovals ?? [])
            ->map(fn ($approval): array => [
                'id' => $approval->id,
                'tool' => str_replace('_', ' ', $approval->tool),
                'reason' => $approval->reason,
            ])
            ->values()
            ->all();
    }

    /**
     * Approve and carry out a publish.
     *
     * Separate from the chat on purpose: the agent can request publication, it
     * cannot perform it. This is the human act.
     */
    public function publish(Request $request): JsonResponse
    {
        $request->validate(['conversation' => ['required', 'string', 'max:36']]);

        $conversation = $this->readableConversation($request, $request->string('conversation')->toString());

        $this->bindWorkspace($conversation->id, $request);

        if (! $campaign = $this->focusedCampaign()) {
            return response()->json(['ok' => false, 'error' => 'No campaign is in focus.', ...$this->panel()], 422);
        }

        $decision = $this->publisher->propose($campaign, $this->actor->describe('publish button'));

        if ($decision->isBlocked()) {
            return response()->json([
                'ok' => false,
                'error' => 'The campaign is not ready to publish.',
                'reasons' => $decision->evidence['blocking'] ?? [],
                ...$this->panel(),
            ], 422);
        }

        try {
            $decision = $this->publisher->approve($decision, $this->actor->describe('publish button'));
        } catch (Throwable $e) {
            return response()->json(['ok' => false, 'error' => $e->getMessage(), ...$this->panel()], 502);
        }

        return response()->json([
            'ok' => $decision->wasApplied(),
            'result' => $decision->result,
            'error' => $decision->reason,
            ...$this->panel(),
        ]);
    }

    // ----------------------------------------------------------------- shared

    /**
     * The right-hand panel, rendered server side so the campaign and the chat
     * can never disagree about what is stored.
     *
     * @return array<string,mixed>
     */
    /**
     * @param  ?Campaign  $viewing  a campaign the buyer clicked to look at,
     *                              which is not necessarily the one being
     *                              worked on. Null means show the focus.
     */
    private function panel(?Campaign $viewing = null): array
    {
        $focus = $this->focusedCampaign();
        $campaign = $viewing ?? $focus;

        return [
            // Named whenever looking and working have come apart, so nobody
            // types an answer believing it applies to the tab on screen.
            'workingOn' => $campaign && $focus && $focus->isNot($campaign) ? $focus->name : null,
            'ready' => $campaign ? $this->gate->passes($campaign) : false,
            'published' => $campaign?->status === 'published',
            // Lets the drawer reveal itself the first time there is something
            // in it, rather than hiding the campaign behind a toggle.
            'hasCampaign' => (bool) $campaign,
            'panelHtml' => view('partials.campaign-panel', [
                'campaign' => $campaign,
                // Named in the panel too, not only in the JSON, because the
                // panel is what somebody is reading when they type.
                'workingOn' => $campaign && $focus && $focus->isNot($campaign) ? $focus->name : null,
                // So the panel shows this platform's fields rather than Meta's.
                // A Google campaign was being given rows for a Facebook Page,
                // an Instagram account and a pixel, all permanently empty.
                'rules' => $campaign ? $this->platforms->for($campaign) : null,
                // Where the buyer is in the build. LaunchFlow has always worked
                // this out to decide the next question; nothing ever showed it,
                // so the product knew the path and the person walking it did not.
                'progress' => $campaign ? $this->flow->progress($campaign) : [],
                'nextStep' => $campaign ? $this->flow->next($campaign) : null,
                'blocking' => $campaign ? $this->gate->check($campaign) : [],
                'sources' => $campaign ? $this->workspace->sourcesFor($campaign) : [],
                // What we hold that the platform does not, so the two are never
                // silently out of step on screen.
                'pending' => $campaign ? $this->pending->updatable($campaign) : [],
                'needsRebuild' => $campaign ? $this->pending->requiringRebuild($campaign) : [],
                // Everything else this conversation holds, so one campaign does
                // not hide the others behind a request to the agent.
                'campaigns' => $this->workspace->isBound()
                    ? $this->workspace->artifacts(Campaign::class)->map(fn ($l) => $l->artifactable)->filter()->values()
                    : collect(),
                'platforms' => $this->platforms,
                'assets' => $this->workspace->isBound()
                    ? $this->workspace->artifacts(Asset::class)->map(fn ($l) => $l->artifactable)->filter()
                    : collect(),
            ])->render(),
        ];
    }

    private function focusedCampaign(): ?Campaign
    {
        if (! $this->workspace->isBound()) {
            return null;
        }

        $focus = $this->workspace->focus();

        return $focus instanceof Campaign ? $focus : $this->latestCampaign();
    }

    private function latestCampaign(): ?Campaign
    {
        return $this->workspace->isBound()
            ? $this->workspace->artifacts(Campaign::class)->first()?->artifactable
            : null;
    }

    /**
     * Turn an upload into an asset, reusing an identical one if it exists.
     *
     * Re-sending the same file is normal: a user retries a failed turn, or
     * attaches the same creative to a second campaign. Without this each retry
     * made another row and another upload to the platform, and the agent then
     * offered the user four creatives when they had given it two.
     */
    private function attach(UploadedFile $file, $owner): Asset
    {
        return $this->assets->fromUpload($file, $owner);
    }

    /** A message is optional when files are attached; say so rather than sending nothing. */
    private function promptFor(array $validated, Request $request): string
    {
        $message = trim((string) ($validated['message'] ?? ''));
        $count = count($request->file('files', []));

        if ($message !== '') {
            return $message;
        }

        return $count > 0
            ? "I have attached {$count} creative(s)."
            : 'Continue.';
    }

    /**
     * Never return an empty bubble: say what is still missing instead.
     *
     * A run paused for approval also arrives here with no text, and the answer
     * then is not what the campaign is missing. Saying "still needed: already
     * published as 120..." reads as a fault when the run is simply waiting on
     * the person to press a button that is on screen.
     *
     * @param  list<array{id: string, tool: string, reason: ?string}>  $awaiting
     */
    private function fallbackText(array $awaiting = []): string
    {
        if ($awaiting !== []) {
            return 'Waiting on your approval below before anything is sent to the platform.';
        }

        $campaign = $this->focusedCampaign();

        if (! $campaign) {
            if ($this->steppedIn !== null && filled($this->steppedIn['ask'] ?? null)) {
                return $this->steppedIn['ask'];
            }

            // The opening question, rather than an invitation to say anything.
            // Asking for the page first is what lets every later step be a
            // recommendation drawn from it instead of a blank question.
            return $this->flow->beforeCampaign(app(Briefs::class)->current())['ask']
                ?? 'Tell me what you want to advertise and I will start a campaign.';
        }

        // A step with no options is asked for in words, and the model sometimes
        // returns none, so the turn ended on a list of what was missing and the
        // person was left with nothing to answer. Steps that do have options
        // are not answered here: offerNextStep() puts those on screen a moment
        // later, and a question above them would be asking twice.
        $next = $this->flow->next($campaign);

        if ($next && ! $next['offers'] && filled($next['ask'] ?? null)) {
            return $next['ask'];
        }

        // A live campaign has nothing left to publish, so the gate answers
        // "already published as 24285639010" and that was spliced into "Still
        // needed:", which reads as gibberish and describes work nobody has.
        // What is outstanding on a live campaign is the edits not yet sent.
        if ($campaign->isLive()) {
            $pendingFields = array_keys($this->pending->updatable($campaign));

            return $pendingFields === []
                ? 'Updated the campaign. Everything here matches what is live.'
                : sprintf(
                    'Updated the campaign here, but %s %s not on the platform yet. Say push to send %s.',
                    implode(' and ', array_map(fn (string $f): string => str_replace('_', ' ', $f), $pendingFields)),
                    count($pendingFields) === 1 ? 'is' : 'are',
                    count($pendingFields) === 1 ? 'it' : 'them',
                );
        }

        $blocking = $this->gate->check($campaign);

        if ($blocking !== []) {
            return 'Updated the campaign. Still needed: '.collect($blocking)->take(4)->implode('; ').'.';
        }

        // Ready is a claim about the whole brief, not about the tab on screen.
        // This said "complete and ready to publish" of a conversation whose
        // other campaign had no ads at all, because every question had been
        // answered against whichever campaign was in focus.
        $elsewhere = $this->flow->outstandingElsewhere($campaign);

        if ($elsewhere === []) {
            return 'The campaign is complete and ready to publish.';
        }

        return sprintf(
            '%s is ready to publish. %s',
            $campaign->name,
            collect($elsewhere)
                ->map(fn (string $step, string $name): string => "{$name} still needs its {$step}")
                ->implode('; ').'.',
        );
    }

    /**
     * The line above the options, when the controller supplied them.
     *
     * The buttons and the sentence over them were describing different steps:
     * bid strategy on screen under "Please write the primary text, headline,
     * description, and call to action for your ad". That happens precisely when
     * the model did not lead to this step, so its words are about another one
     * and the step's own sentence is the honest thing to show.
     *
     * Two replies are never replaced. An approval in flight is a button to
     * press rather than a question to answer, and a turn that failed has
     * already said what broke and how far it got, which matters more than what
     * to do next and is the only record the person gets of it.
     *
     * @param  list<array{id: string, tool: string, reason: ?string}>  $awaiting
     */
    private function questionFor(string $reply, array $awaiting, bool $failed = false): string
    {
        if ($failed || $awaiting !== [] || $this->steppedIn === null) {
            return $reply;
        }

        $ask = $this->steppedIn['ask'] ?? null;

        if (blank($ask)) {
            return $reply;
        }

        // Kept, not replaced.
        //
        // This returned the ask alone, so a turn where the controller stepped
        // in threw away whatever the model had just said. On arb-dev on 25 Sep
        // that showed the buyer "Choose a daily amount" three turns running
        // while the budget was stuck, and the three replies explaining why 30
        // was refused, why the even split could not be set, and what the
        // ceiling was, went to the database and were never read.
        //
        // Replacing it with the model's words instead is the opposite mistake,
        // and one this code has already made: on 16 Sep the browser showed the
        // three bid strategy buttons under "Please write the primary text,
        // headline, description, and call to action for your ad", because the
        // model had wandered off the step whose options were on screen. That
        // is the whole reason the ask is here, and QuestionMatchesOptionsTest
        // holds the line.
        //
        // So both: what happened, then the question that belongs to the
        // buttons underneath it. The explanation is what the buyer needs to
        // stop repeating themselves, and the question is what keeps the prose
        // and the options describing the same step.
        if ($reply === '' || str_contains($reply, $ask)) {
            return $reply === '' ? $ask : $reply;
        }

        return $reply."\n\n".$ask;
    }

    /**
     * Put the next step's options on screen when the model did not.
     *
     * The model is told which tool to call and still answers from the
     * transcript, naming a value in prose and asking "shall I use it?". A
     * guided flow cannot depend on it choosing to guide, so when a turn ends
     * with an outstanding step and nothing offered, the step's own tool is
     * called here.
     *
     * Only ever offering tools, only when nothing was offered, and failures are
     * swallowed: a lookup that cannot reach the platform must not take the
     * reply down with it.
     */
    private function offerNextStep(?string $userMessage = null): void
    {
        if ($this->choices->all() !== []) {
            return;
        }

        $campaign = $this->focusedCampaign();

        if (! $campaign) {
            if ($this->wantsMultiPlatform($userMessage)) {
                $tool = collect($this->registry->resolve())
                    ->first(fn ($t): bool => $t->name() === 'campaign__start_campaign');

                if ($tool) {
                    try {
                        // Named platforms are passed through rather than asked
                        // about again. Calling with no arguments always offers
                        // the chooser, so "advertise this on Meta and Google"
                        // was answered with "Where should this run?".
                        $named = $this->platformsNamedIn($userMessage);

                        $tool->handle(new ToolRequest($named === [] ? [] : ['platforms' => $named]));

                        $this->steppedIn = $named === []
                            ? ['ask' => 'Where should this run? Choose one or more platforms below.']
                            : null;

                        if ($named === []) {
                            return;
                        }

                        // The campaigns exist now, so the check below finds
                        // one and the rest of this method offers its next step.
                    } catch (Throwable $e) {
                        report($e);
                    }
                }
            }

            if (! $campaign = $this->focusedCampaign()) {
                // Still nothing to offer, but the opening question needs to
                // reach the user if the model did not ask it.
                $this->steppedIn = $this->flow->beforeCampaign(app(Briefs::class)->current());

                return;
            }
        }

        if (! $next = $this->flow->next($campaign)) {
            return;
        }

        // Some steps have no options to show. Calling those with no arguments
        // only produces a refusal nobody sees.
        if (! $next['offers']) {
            return;
        }

        $tool = collect($this->registry->resolve())
            ->first(fn ($t): bool => $t->name() === 'campaign__'.$next['tool']);

        if (! $tool) {
            return;
        }

        try {
            $tool->handle(new ToolRequest([]));

            $this->steppedIn = $next;
        } catch (Throwable $e) {
            report($e);
        }
    }

    /**
     * Whether the buyer has raised where their ads should run.
     *
     * The word is the signal, not the sentence around it. This was fifteen
     * hardcoded phrases, which is a guess at how somebody will phrase a
     * question: "in any platform" matched none of them, "run this on fb and
     * google" still matches nothing, and seven of the fifteen were dead
     * because Str::contains already finds the singular inside the plural.
     *
     * Answering is cheap and safe to get wrong in this direction: putting the
     * platform choices on screen creates no campaign and settles nothing, so
     * the opening question is still there afterwards. Missing the ask is the
     * expensive half, because the buyer is left with prose where a button was
     * the whole point. Firing when they were only asking costs a set of buttons
     * they can ignore, and missing it costs prose where a button was the whole
     * point.
     *
     * Purely informational questions ("what platforms do you support?") with
     * no action or intent to run or use platforms are answered in prose, but any
     * query expressing campaign intent (advertise, run, use, launch, etc.)
     * presents the chooser.
     */
    private function wantsMultiPlatform(?string $message): bool
    {
        if (! filled($message)) {
            return false;
        }

        if ($this->isInformationalQuery($message)) {
            return false;
        }

        return str_contains(strtolower($message), 'platform') || $this->platformsNamedIn($message) !== [];
    }

    /**
     * Questions inquiring about capabilities or information rather than initiating an action.
     */
    private function isInformationalQuery(string $message): bool
    {
        $normalized = strtolower(trim($message));

        // Queries indicating an active intent to run/launch/use platforms are not informational
        if (preg_match('/\b(start|create|run|launch|build|setup|advertise|promote|buy|use)\b/i', $normalized)) {
            return false;
        }

        // Question asking for information about platforms without active intent
        if (preg_match('/^(what|which|how|tell me|explain|can you tell|list|do you support)\b/i', $normalized)) {
            return true;
        }

        // Questions ending in ? without an active intent to launch/build a campaign
        if (str_ends_with($normalized, '?')) {
            return true;
        }

        return false;
    }

    /**
     * The platforms this message names, by their own names.
     *
     * "run this on fb and google" is as clear an answer as clicking two cards,
     * and asking afterwards is asking a question that has been answered. On
     * arb-dev on 25 Sep, "Advertise https://iblockads.org/ on Meta and Google"
     * was met with "Where should this run?", because the step-in called
     * start_campaign with no arguments and that always offers the chooser.
     *
     * Ordered as the platform list is, not as the sentence is, so two campaigns
     * are created in the same order every time and the first one focused is
     * predictable.
     *
     * @return list<string>
     */
    private function platformsNamedIn(?string $message): array
    {
        if (! filled($message)) {
            return [];
        }

        $normalized = strtolower($message);

        $aliases = [
            'meta' => ['meta', 'facebook', 'fb', 'instagram', 'ig'],
            'google' => ['google', 'gads', 'adwords'],
            'linkedin' => ['linkedin'],
            'tiktok' => ['tiktok'],
            'taboola' => ['taboola'],
        ];

        $named = [];

        foreach ($this->platforms->keys() as $key) {
            foreach ($aliases[$key] ?? [$key] as $alias) {
                if (preg_match('/\b'.preg_quote($alias, '/').'\b/', $normalized)) {
                    $named[] = $key;
                    break;
                }
            }
        }

        return $named;
    }

    /**
     * What this turn offered, kept so a reload can draw it again.
     *
     * @return list<array<string, mixed>>
     */
    private function rememberedChoices(?string $userMessage = null): array
    {
        $this->offerNextStep($userMessage);

        $offered = $this->choices->drain();

        // Written every turn, including when nothing was offered, so an
        // answered question clears rather than lingering.
        $this->workspace->rememberChoices($offered);

        return $offered;
    }

    /**
     * Determine whether the incoming request should be streamed.
     */
    private function shouldStream(Request $request): bool
    {
        return $request->header('Accept') === 'text/event-stream' || $request->boolean('stream');
    }

    /**
     * Finalize the turn and build the response payload.
     *
     * Shared between synchronous and streaming responses so that settling the
     * brief, remembering choices, fallback text, question phrasing, and the
     * campaign panel stay identical across both paths.
     *
     * @param  list<array{id: string, tool: string, reason: ?string}>  $awaiting
     * @return array<string, mixed>
     */
    private function finalizeTurn(
        MarketingAgent $agent,
        Request $request,
        string $message,
        string $reply,
        array $awaiting,
        bool $failed,
    ): array {
        // Bound again now the conversation exists. On the first message of a
        // new chat it did not when the turn started, so everything written
        // through the workspace that turn had nowhere to go. Binding here
        // flushes what was held and lets the rest of this request record
        // normally.
        $this->bindWorkspace($agent->currentConversation(), $request);

        // And settles the brief, which needed the same conversation. A first
        // message naming the page and the platforms at once creates both
        // campaigns before there is anywhere to record what they share, so
        // without this they end up sharing nothing: no landing page, no budget
        // to split, and every question asked once per platform.
        $this->briefs->settle();

        // Resolved before the reply is settled: this is what decides whether
        // the controller had to step in, and therefore whether the model's
        // words are about the step whose options are now on screen.
        $choices = $this->rememberedChoices($message);

        $reply = $reply !== '' ? $reply : $this->fallbackText($awaiting);
        $reply = $this->questionFor($reply, $awaiting, $failed);

        $this->recordDisplayedReply($agent->currentConversation(), $reply);

        return [
            'conversation' => $agent->currentConversation(),
            'conversation_title' => $this->conversationTitle($agent->currentConversation()),
            'reply' => $reply,
            'replyHtml' => (string) Markdown::toHtml($reply),
            'awaiting_approval' => $awaiting,
            'choices' => $choices,
            ...$this->panel(),
        ];
    }

    /**
     * Stream a turn using Server-Sent Events.
     */
    private function streamTurn(
        MarketingAgent $agent,
        Request $request,
        string $message,
        mixed $before,
        string $untouched,
    ): StreamedResponse {
        return response()->stream(function () use ($agent, $request, $message, $before, $untouched): void {
            ignore_user_abort(true);

            if (ob_get_level() > 0) {
                @ob_end_flush();
            }

            $awaiting = [];
            $failed = false;
            $reply = '';
            $attempt = 0;
            $echoedAny = false;

            while (true) {
                try {
                    $stream = $agent->stream($message);

                    foreach ($stream as $event) {
                        if ($event instanceof TextDelta) {
                            $echoedAny = true;
                            echo 'data: '.json_encode([
                                'type' => 'delta',
                                'delta' => $event->delta,
                            ])."\n\n";
                            @flush();
                        }
                    }

                    $reply = trim((string) $stream->text);
                    $awaiting = $this->awaiting($stream->streamedResponse ?? null);
                    break;
                } catch (Throwable $e) {
                    $attempt++;

                    $worthRetrying = ! $echoedAny
                        && $attempt < self::PROVIDER_ATTEMPTS
                        && $this->isTransient($e)
                        && $this->footprint() === $untouched;

                    if ($worthRetrying) {
                        usleep($attempt * 400_000);

                        continue;
                    }

                    report($e);
                    $failed = true;

                    $reply = 'That step failed: '.$this->readableFailure($e)."\n\n".$this->howFarItGot($before);
                    $this->recordFailedTurn($agent->currentConversation(), $request->user(), $message, $reply);

                    echo 'data: '.json_encode([
                        'type' => 'error',
                        'error' => $reply,
                    ])."\n\n";
                    @flush();
                    break;
                }
            }

            $payload = [
                'type' => 'done',
                ...$this->finalizeTurn($agent, $request, $message, $reply, $awaiting, $failed),
            ];

            echo 'data: '.json_encode($payload)."\n\n";
            echo "data: [DONE]\n\n";
            @flush();
        }, 200, [
            'Content-Type' => 'text/event-stream',
            'Cache-Control' => 'no-cache',
            'Connection' => 'keep-alive',
            'X-Accel-Buffering' => 'no',
        ]);
    }

    /**
     * Run the turn, and try again when the provider was merely busy.
     *
     * Only ever retried when the first attempt left no trace. A turn that ran
     * three tools and died on the fourth must not be replayed: that is how one
     * campaign becomes two, which this product has already done once.
     *
     * The proof is a footprint taken before and after. Every platform call is
     * recorded in api_calls and every campaign is a row, so if neither moved,
     * nothing was created and nothing was sent, and the attempt can be treated
     * as though it never happened.
     */
    private function promptWithRetry(MarketingAgent $agent, string $message, string $untouched)
    {
        $attempt = 0;

        while (true) {
            try {
                return $agent->prompt($message);
            } catch (Throwable $e) {
                $attempt++;

                $worthRetrying = $attempt < self::PROVIDER_ATTEMPTS
                    && $this->isTransient($e)
                    && $this->footprint() === $untouched;

                if (! $worthRetrying) {
                    throw $e;
                }

                // Short, and growing: a provider shedding load needs a moment,
                // and the person is watching a spinner while we wait.
                usleep($attempt * 400_000);
            }
        }
    }

    /** Busy, not broken. Anything else is not worth a second attempt. */
    private function isTransient(Throwable $e): bool
    {
        $message = strtolower($e->getMessage());

        // A tool name the model made up. Transient in the sense that matters
        // here: it is one bad sample, nothing was written, and asking again
        // usually produces a real tool name. Seen twice on dev on 23 Sep, as
        // campaign__set_budget and campaign__choose_campaign_cie_or_something_else,
        // each of which ended the turn and showed the buyer the invented name.
        if ($e instanceof NoSuchToolException) {
            return true;
        }

        foreach (['overloaded', 'rate limit', 'timed out', 'timeout', '429', '503', '502', '504'] as $sign) {
            if (str_contains($message, $sign)) {
                return true;
            }
        }

        return false;
    }

    /** Enough to tell whether an attempt touched anything at all. */
    private function footprint(): string
    {
        return implode(':', [
            ApiCall::count(),
            Campaign::count(),
            Campaign::max('updated_at') ?? '',
        ]);
    }

    /**
     * A failure in a sentence, not a provider's response body.
     *
     * These reached the user whole: a 401 arrived as four lines of JSON with
     * the error object still in it. What a media buyer needs to know is whether
     * to try again or to fetch somebody.
     */
    private function readableFailure(Throwable $e): string
    {
        $message = trim(strtok($e->getMessage(), "\n") ?: '');

        return match (true) {
            // A quota window, which is not the same thing as a busy server and
            // must not be described as one. These used to share a message that
            // said "send it again in a moment": the send fails identically
            // until the window resets, so the advice was worse than none, and
            // the user retried three or four times before giving up.
            str_contains(strtolower($message), 'rate limit'),
            str_contains($message, '429') => $this->rateLimited($e),

            // Genuinely transient. Here "in a moment" is the right advice,
            // which is why it is no longer shared with the case above.
            str_contains($message, 'overloaded'),
            str_contains($message, '503') => 'the AI provider is busy. Send it again in a moment.',

            str_contains($message, 'not authorized'),
            str_contains($message, '401') => 'the AI provider refused our credentials. This needs an administrator, not a retry.',

            // The model reached for a tool that does not exist. Retried already
            // and still wrong, so all the buyer needs is that nothing happened;
            // the invented name is ours to debug, not theirs to read.
            $e instanceof NoSuchToolException => 'I reached for something that is not there. Nothing was changed. Say that again and I will take a different route.',

            // Anything unrecognised, trimmed so a JSON body cannot arrive whole.
            default => rtrim(mb_substr($message, 0, 160), " :\t").'.',
        };
    }

    /**
     * How long the rate limit has left, said plainly.
     *
     * Retried already, three times, before reaching here: promptWithRetry backs
     * off for about a second in total, which clears a momentary spike and never
     * clears a quota window. So by this point the honest thing to say is that
     * waiting is the only thing that will work, and for roughly how long.
     */
    /**
     * Shared with the creative studio, which was still saying "try again".
     *
     * @see ProviderFailure
     */
    private function rateLimited(Throwable $e): string
    {
        return ProviderFailure::rateLimited($e);
    }

    /**
     * The wait the provider asked for, if it named one.
     *
     * Taken from the Retry-After header where the exception carries a response,
     * and otherwise from the message, because several providers state the wait
     * in prose and never set the header at all.
     */
    /**
     * How far the turn got before it broke.
     *
     * This used to say "Nothing in the campaign was changed" whatever had
     * happened. A turn can run four tools and fail on the fifth, and telling
     * someone nothing changed when the budget just did is worse than saying
     * nothing at all.
     */
    private function howFarItGot(?CarbonInterface $before): string
    {
        $after = $this->focusedCampaign()?->fresh()?->updated_at;

        if ($after === null || $before?->equalTo($after)) {
            return 'Nothing in the campaign was changed.';
        }

        return 'Some of it was applied before the failure. The panel shows where it got to.';
    }

    /**
     * Bind the workspace and record who it belongs to.
     *
     * Ownership was never written anywhere: ownedBy() existed and nothing
     * called it, so every conversation was ownerless and anything asking the
     * workspace who the user is got null. This is the one place that knows.
     */
    private function bindWorkspace(?string $conversationId, Request $request): void
    {
        if ($conversationId) {
            $this->workspace->bindTo($conversationId);
        }

        if ($user = $request->user()) {
            $this->workspace->ownedBy($user);
        }
    }

    private function resolveConversation(Request $request): ?Conversation
    {
        // Asked for explicitly, so do not fall back to the last conversation.
        if ($request->boolean('new')) {
            return null;
        }

        $id = $request->string('conversation')->toString();

        return $id !== ''
            ? $this->readableConversation($request, $id)
            : $this->recentConversations($request)->first();
    }

    /**
     * A conversation this user is allowed to open, or a 404.
     *
     * Access was deliberately open once: an internal tool on shared ad
     * accounts, where two people administering the same account had to see the
     * same work, with attribution standing in for an ownership check. That is
     * now opt-in through agent.shared_conversations, because it also meant
     * anyone signed in could read and write anyone else's chat from its URL.
     *
     * Scoping only the sidebar would hide other people's chats from the list
     * while leaving them reachable: send() resolves through here too.
     *
     * 404 rather than 403, so the reply does not confirm that an id exists.
     */
    private function readableConversation(Request $request, string $id): Conversation
    {
        $conversation = Conversation::find($id);

        // Both guards were lost in the google-mcp merge, which left this
        // returning whatever Conversation::find() gave it, including null.
        abort_if($conversation === null || $conversation->chat_type === 'google_ads', 404);
        abort_unless($this->mayOpen($request, $conversation), 404);

        return $conversation;
    }

    /**
     * Whether this conversation belongs to whoever is asking.
     *
     * Shared conversations were deliberate once - one list, everyone's work.
     * Private is the default now; config('agent.shared_conversations') puts the
     * old behaviour back without a code change.
     */
    private function mayOpen(Request $request, Conversation $conversation): bool
    {
        if (config('agent.shared_conversations')) {
            return true;
        }

        $user = $request->user();

        if ($user === null) {
            return false;
        }

        // A conversation with no participant recorded predates ownership being
        // tracked. Readable by anyone rather than by nobody, so old work does
        // not simply vanish from the product.
        if (blank($conversation->participant_id)) {
            return true;
        }

        return (string) $conversation->participant_id === (string) $user->getKey()
            && (string) $conversation->participant_type === $user->getMorphClass();
    }

    private function conversationTitle(?string $id): string
    {
        $title = $id ? Conversation::find($id)?->title : null;

        return trim((string) $title) ?: 'Untitled';
    }

    /**
     * The chats to list in the sidebar.
     *
     * The signed-in user's own, unless sharing is turned back on. Conversations
     * with no participant recorded predate ownership being tracked and are kept
     * visible, so existing work does not disappear.
     */
    private function recentConversations(Request $request)
    {
        // Through readable(), so the sidebar, the /chats index and opening one
        // by URL are three uses of one rule rather than three copies of it.
        return $this->readable($request)
            ->whereNull('chat_type')
            ->latest('updated_at')
            ->limit(30)
            ->get();
    }

    /**
     * The user-facing transcript.
     *
     * Tool traffic is excluded: it is in the audit log, and replaying it in the
     * chat buries the sentence the user is actually meant to read.
     *
     * @return list<array{role: string, text: string, html: ?string}>
     */

    /**
     * What this message said, including the turns that said it without words.
     *
     * A tool that stops for approval ends the turn with the call pending and no
     * assistant text, so the row is stored empty and the transcript dropped it.
     * Reloading then showed "Approve and publish the campaign." answered by
     * nothing at all, as though the click had gone nowhere.
     *
     * Seen on arb-dev on 25 Sep. The approval sat unresolved, the buyer moved
     * on, and the record of what the product was waiting for existed only in
     * the JSON response of the turn that had already scrolled past. The live
     * turn says this same sentence through fallbackText(); this is the half
     * that survives a reload.
     */
    private function said(object $message): string
    {
        $text = trim((string) $message->content);

        if ($text !== '' || $message->role === 'user') {
            return $text;
        }

        $state = json_decode((string) ($message->approval_state ?? ''), true);

        return filled($state['pending'] ?? null)
            ? 'Waiting for your approval before anything is sent to the platform.'
            : '';
    }

    private function transcript(?Conversation $conversation): array
    {
        if (! $conversation) {
            return [];
        }

        return DB::table('agent_conversation_messages')
            ->where('conversation_id', $conversation->id)
            ->whereIn('role', ['user', 'assistant'])
            ->orderBy('created_at')
            ->orderBy('id')
            ->get(['role', 'content', 'approval_state'])
            ->map(fn ($m): array => [
                'role' => $m->role === 'user' ? 'user' : 'agent',
                'text' => $this->said($m),
            ])
            // Genuinely empty turns are dropped, as they always were. What is
            // no longer dropped is a turn that paused for approval.
            ->filter(fn (array $m): bool => $m['text'] !== '')
            ->map(fn (array $m): array => [
                ...$m,
                'html' => $m['role'] === 'user' ? null : (string) Markdown::toHtml($m['text']),
            ])
            ->values()
            ->all();
    }
}
