<?php

namespace App\Http\Controllers;

use App\Agent\MarketingAgent;
use App\Agent\Workspace;
use App\Campaigns\PendingChanges;
use App\Campaigns\Publisher;
use App\Campaigns\PublishGate;
use App\Models\Asset;
use App\Models\Campaign;
use App\Support\Markdown;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Laravel\Ai\Approvals\Decision;
use Laravel\Ai\Approvals\Decisions;
use Laravel\Ai\Models\Conversation;
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
{
    public function __construct(
        private readonly MarketingAgent $agent,
        private readonly Workspace $workspace,
        private readonly PublishGate $gate,
        private readonly Publisher $publisher,
        private readonly PendingChanges $pending,
    ) {}

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

        if ($conversation) {
            $this->workspace->bindTo($conversation->id);
        }

        return view('chat', [
            'conversationId' => $conversation?->id,
            '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),
            '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
    {
        $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'],
        ]);

        $agent = $validated['conversation'] ?? null
            ? $this->agent->continue(
                $this->ownedConversation($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->workspace->bindTo($agent->currentConversation());

        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.
        if ($campaign = $this->latestCampaign()) {
            $this->workspace->focusOn($campaign);
        }

        $awaiting = [];

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

        try {
            $response = $agent->prompt($message);
            $reply = trim((string) $response->text);
            $awaiting = $this->awaiting($response);
        } catch (Throwable $e) {
            report($e);

            // Never dead-end. Say what broke and confirm nothing changed.
            $reply = "That step failed: {$e->getMessage()}\n\nNothing in the campaign was changed.";

            // 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);
        }

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

        return response()->json([
            'conversation' => $agent->currentConversation(),
            // The first message is what brings a conversation into being, so
            // the sidebar has no row for it yet. Send the title back and the
            // list can add one without a reload.
            'conversation_title' => $this->conversationTitle($agent->currentConversation()),
            'reply' => $reply,
            // Models write lists and bold. Rendered as plain text a good answer
            // reads as a wall of hyphens, so the markup is built server side
            // where the input can be escaped first.
            'replyHtml' => (string) Markdown::toHtml($reply),
            'awaiting_approval' => $awaiting,
            ...$this->panel(),
        ]);
    }

    /**
     * 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.
     */
    public function focus(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'conversation' => ['required', 'string', 'max:36'],
            'campaign' => ['required', 'integer'],
        ]);

        $conversation = $this->ownedConversation($request, $validated['conversation']);
        $this->workspace->bindTo($conversation->id);

        $artifact = $this->workspace->artifacts(Campaign::class)
            ->first(fn ($link): bool => (int) $link->artifactable_id === $validated['campaign']);

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

        $this->workspace->focusOn($artifact->artifactable);

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

    /**
     * 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->ownedConversation($request, $validated['conversation']);

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

        $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,
            '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],
        ]);
    }

    /**
     * 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($response): array
    {
        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->ownedConversation($request, $request->string('conversation')->toString());

        $this->workspace->bindTo($conversation->id);

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

        $decision = $this->publisher->propose($campaign, 'web');

        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, 'web');
        } 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>
     */
    private function panel(): array
    {
        $campaign = $this->focusedCampaign();

        return [
            'ready' => $campaign ? $this->gate->passes($campaign) : false,
            'published' => $campaign?->status === 'published',
            'panelHtml' => view('partials.campaign-panel', [
                'campaign' => $campaign,
                '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(),
                '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
    {
        $checksum = hash_file('sha256', $file->getRealPath());

        $existing = Asset::query()
            ->where('checksum', $checksum)
            ->where('owner_type', $owner?->getMorphClass())
            ->where('owner_id', $owner?->getKey())
            ->first();

        if ($existing) {
            return $existing;
        }

        return Asset::create([
            'owner_type' => $owner?->getMorphClass(),
            'owner_id' => $owner?->getKey(),
            'kind' => str_starts_with((string) $file->getMimeType(), 'video/') ? 'video' : 'image',
            'source' => 'upload',
            'path' => $file->store('assets'),
            'original_name' => $file->getClientOriginalName(),
            'mime' => $file->getMimeType(),
            'bytes' => $file->getSize(),
            'checksum' => $checksum,
        ]);
    }

    /** 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();
        $blocking = $campaign ? $this->gate->check($campaign) : [];

        if (! $campaign) {
            return 'Tell me what you want to advertise and I will start a campaign.';
        }

        return $blocking === []
            ? 'The campaign is complete and ready to publish.'
            : 'Updated the campaign. Still needed: '.collect($blocking)->take(4)->implode('; ').'.';
    }

    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->ownedConversation($request, $id)
            : $this->recentConversations($request)->first();
    }

    /**
     * A conversation, only if it belongs to whoever is asking.
     *
     * The single place ownership is decided. Every route that names a
     * conversation goes through here, because the ids are guessable enough and
     * the consequence is not just reading someone else's chat: it is publishing
     * their campaign to their ad account.
     *
     * Missing and forbidden are both 404 on purpose, so the response cannot be
     * used to discover which ids exist.
     */
    private function ownedConversation(Request $request, string $id): Conversation
    {
        $conversation = Conversation::find($id);

        abort_if(
            $conversation === null
            || $conversation->participant_type !== $request->user()?->getMorphClass()
            || (string) $conversation->participant_id !== (string) $request->user()?->getKey(),
            404,
        );

        return $conversation;
    }

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

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

    private function recentConversations(Request $request)
    {
        return Conversation::query()
            ->where('participant_type', $request->user()?->getMorphClass())
            ->where('participant_id', $request->user()?->getKey())
            ->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}>
     */
    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'])
            ->filter(fn ($m): bool => trim((string) $m->content) !== '')
            ->map(fn ($m): array => [
                'role' => $m->role === 'user' ? 'user' : 'agent',
                'text' => (string) $m->content,
                'html' => $m->role === 'user' ? null : (string) Markdown::toHtml((string) $m->content),
            ])
            ->values()
            ->all();
    }
}
