<?php

namespace Tests\Feature;

use App\Agent\MarketingAgent;
use App\Agent\Workspace;
use App\Models\Asset;
use App\Models\Campaign;
use App\Models\ConversationArtifact;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;

/**
 * The chat surface.
 *
 * These exercise the HTTP layer, not the model: what matters here is that a
 * message reaches the agent, an attachment becomes an asset linked to the
 * conversation, and publishing is refused unless the gate allows it.
 */
class ChatControllerTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();

        $this->user = User::factory()->create();
        $this->actingAs($this->user);

        // The agent must never reach a real provider from a test. Without this
        // every send() would spend tokens and take seconds, and the suite would
        // fail whenever the provider was busy rather than when the code broke.
        MarketingAgent::fake(['Noted.']);
    }

    protected User $user;

    /**
     * A real conversation row owned by the acting user.
     *
     * The endpoints refuse a conversation the caller does not own, so a made-up
     * id now returns 404 rather than reaching the handler. That is the point of
     * the check; these tests just have to own what they talk about.
     */
    private function ownedConversation(string $id): string
    {
        DB::table('agent_conversations')->insert([
            'id' => $id,
            'participant_type' => $this->user->getMorphClass(),
            'participant_id' => $this->user->getKey(),
            'title' => 'Test conversation',
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        return $id;
    }

    /**
     * "New chat" has to be blank.
     *
     * It redirected to /chat, which opens the most recent conversation, so the
     * button put you straight back in the chat you were trying to leave.
     */
    public function test_new_chat_does_not_reopen_the_last_conversation(): void
    {
        $conversation = $this->ownedConversation('conv-chat-0007');

        DB::table('agent_conversation_messages')->insert([
            'id' => (string) Str::uuid7(),
            'conversation_id' => $conversation,
            'participant_type' => $this->user->getMorphClass(),
            'participant_id' => $this->user->getKey(),
            'agent' => MarketingAgent::class,
            'role' => 'user',
            'content' => 'Budget is eighty pounds a day.',
            'attachments' => '[]',
            'tool_calls' => '[]',
            'tool_results' => '[]',
            'usage' => '[]',
            'meta' => '[]',
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        // Landing on /chat with nothing asked for still opens the latest one.
        $this->get(route('chat.show'))
            ->assertOk()
            ->assertSee('Budget is eighty pounds a day.', escape: false);

        $this->get(route('chat.new'))
            ->assertRedirect(route('chat.show', ['new' => 1]));

        $this->get(route('chat.show', ['new' => 1]))
            ->assertOk()
            ->assertDontSee('Budget is eighty pounds a day.', escape: false);
    }

    /**
     * Switching campaigns from the panel.
     *
     * A conversation can hold several. The endpoint takes an id from the page,
     * so it has to refuse one this conversation does not own rather than focus
     * whatever it is handed.
     */
    /**
     * Looking at a campaign does not change which one is worked on.
     *
     * This asserted the opposite, because clicking a tab used to call
     * focusOn(). 25 of the 27 campaign tools act on the focus, so a look
     * retargeted every answer that followed it, silently.
     */
    public function test_viewing_a_campaign_leaves_the_focus_alone(): void
    {
        $conversation = $this->ownedConversation('conv-chat-0008');

        $workspace = app(Workspace::class);
        $workspace->bindTo($conversation);

        $first = Campaign::create([
            'user_id' => auth()->id(),
            'name' => 'Winter Sale',
            'platform' => 'meta',
            'status' => 'draft',
        ]);
        $second = Campaign::create([
            'user_id' => auth()->id(),
            'name' => 'Spring Sale',
            'platform' => 'meta',
            'status' => 'draft',
        ]);

        $workspace->produced($first);
        $workspace->produced($second);

        $response = $this->postJson(route('chat.view'), [
            'conversation' => $conversation,
            'campaign' => $first->id,
        ])->assertOk()->assertJsonPath('ok', true);

        // The panel shows the one that was clicked.
        $this->assertStringContainsString('Winter Sale', $response->json('panelHtml'));

        // produced() focuses what it creates, so the second is still the one
        // being worked on, and clicking the first did not change that.
        $this->assertSame($second->id, $workspace->focus()?->id);
        $this->assertSame('Spring Sale', $response->json('workingOn'));
    }

    public function test_focus_refuses_a_campaign_from_another_conversation(): void
    {
        $conversation = $this->ownedConversation('conv-chat-0009');
        $elsewhere = $this->ownedConversation('conv-chat-0010');

        $workspace = app(Workspace::class);
        $workspace->bindTo($elsewhere);

        $outsider = Campaign::create([
            'user_id' => auth()->id(),
            'name' => 'Not in this chat',
            'platform' => 'meta',
            'status' => 'draft',
        ]);
        $workspace->produced($outsider);

        $this->postJson(route('chat.view'), [
            'conversation' => $conversation,
            'campaign' => $outsider->id,
        ])->assertNotFound();
    }

    /**
     * A conversation has to know whose it is.
     *
     * ownedBy() existed and nothing in the application called it, so every
     * conversation was ownerless and any tool asking the workspace who the user
     * is got null. That is how importing a generated creative failed with "no
     * signed-in user" while the user was plainly signed in.
     */
    public function test_sending_records_who_the_conversation_belongs_to(): void
    {
        $this->postJson(route('chat.send'), ['message' => 'hello'])->assertOk();

        $workspace = app(Workspace::class);

        $this->assertNotNull($workspace->owner(), 'The conversation should know its owner.');
        $this->assertSame($this->user->id, $workspace->owner()->id);
    }

    /**
     * The whole tag carrying $marker, so a class can be looked for inside it.
     *
     * Asserting class="a b" against raw HTML pins the attribute order and the
     * exact set of classes, and it fails open: assertDontSee('class="mb-bell"')
     * passes the moment somebody writes class="mb-bell is-on", which is the one
     * case it exists to catch.
     */
    private function tagCarrying(string $html, string $marker): string
    {
        $at = strpos($html, $marker);

        if ($at === false) {
            return '';
        }

        $open = strrpos(substr($html, 0, $at), '<');
        $close = strpos($html, '>', $at);

        return $open === false || $close === false ? '' : substr($html, $open, $close - $open + 1);
    }

    /** @return list<string> */
    private function classesOn(string $html, string $marker): array
    {
        $tag = $this->tagCarrying($html, $marker);

        $this->assertNotSame('', $tag, "No element carrying {$marker}.");

        preg_match('/class="([^"]*)"/', $tag, $found);

        return preg_split('/\s+/', trim($found[1] ?? '')) ?: [];
    }

    public function test_the_chat_page_loads_with_an_empty_panel(): void
    {
        $response = $this->get(route('chat.show'))
            ->assertOk()
            ->assertSee('New chat')
            ->assertSee('No campaign yet', escape: false)
            ->assertSee('id="starterPrompts"', escape: false)
            ->assertDontSee('Multi-Platform Campaign')
            ->assertSee('Meta Ads Campaign')
            ->assertSee('Google Ads Campaign')
            ->assertSee('LinkedIn Ads Campaign')
            ->assertSee('id="scrollBottomBtn"', escape: false);

        $html = $response->getContent();

        $this->assertContains('mb-icon-btn', $this->classesOn($html, 'data-pop="notif"'));

        // Anywhere, not only as the whole attribute, so an added class cannot
        // hide the markup this replaced.
        $this->assertStringNotContainsString('mb-bell', $html);
    }

    public function test_campaign_sidebar_is_highlighted_and_chat_is_reduced_when_campaign_exists(): void
    {
        $conversation = $this->ownedConversation('conv-chat-campaign-state');

        $workspace = app(Workspace::class);
        $workspace->bindTo($conversation);

        $campaign = Campaign::create([
            'user_id' => auth()->id(),
            'name' => 'Active Test Campaign',
            'platform' => 'linkedin',
            'status' => 'draft',
        ]);
        $workspace->produced($campaign);

        $html = $this->get(route('chat.show', ['conversation' => $conversation]))
            ->assertOk()
            ->getContent();

        // Asserted as class membership, not as the exact attribute. Three
        // elements are pinned here and Track 1 is about to rework this markup
        // for streaming and the drawer; a reordered attribute or one added
        // class would have failed all three while nothing was actually wrong.
        $this->assertContains('is-active', $this->classesOn($html, 'data-action="toggle-campaign"'));
        $this->assertContains('open', $this->classesOn($html, 'id="campaignDrawer"'));
        // Found by the section's own data attribute: "mb-chat" as a marker
        // matches mb-chat-scroll on the element inside it first.
        $this->assertContains('has-campaign-open', $this->classesOn($html, 'data-context='));
    }

    public function test_the_chat_requires_authentication(): void
    {
        auth()->logout();

        $this->get(route('chat.show'))->assertRedirect(route('login'));
        $this->post(route('chat.send'), ['message' => 'hello'])->assertRedirect(route('login'));
    }

    public function test_an_attachment_becomes_an_asset_linked_to_the_conversation(): void
    {
        Storage::fake('local');

        $conversation = $this->ownedConversation('conv-chat-0001');

        $workspace = app(Workspace::class);
        $workspace->bindTo($conversation);

        $this->post(route('chat.send'), [
            'conversation' => $conversation,
            'message' => '',
            'files' => [UploadedFile::fake()->image('creative.png', 600, 600)],
        ]);

        $asset = Asset::first();

        $this->assertNotNull($asset, 'The upload should have become an asset.');
        $this->assertSame('upload', $asset->source);
        $this->assertSame('creative.png', $asset->original_name);
        $this->assertNotNull($asset->checksum);
        Storage::disk('local')->assertExists($asset->path);

        $this->assertSame(1, ConversationArtifact::where('conversation_id', 'conv-chat-0001')->count());
    }

    public function test_publishing_is_refused_while_the_campaign_is_incomplete(): void
    {
        $conversation = $this->ownedConversation('conv-chat-0002');

        $workspace = app(Workspace::class);
        $workspace->bindTo($conversation);

        $campaign = Campaign::create([
            'user_id' => auth()->id(),
            'name' => 'Half built',
            'platform' => 'meta',
            'status' => 'draft',
        ]);
        $workspace->produced($campaign);

        $this->postJson(route('chat.publish'), ['conversation' => $conversation])
            ->assertStatus(422)
            ->assertJsonPath('ok', false)
            ->assertJsonFragment(['error' => 'The campaign is not ready to publish.']);

        $this->assertSame('draft', $campaign->fresh()->status);
    }

    public function test_publishing_without_a_campaign_is_refused(): void
    {
        $conversation = $this->ownedConversation('conv-chat-0003');
        app(Workspace::class)->bindTo($conversation);

        $this->postJson(route('chat.publish'), ['conversation' => $conversation])
            ->assertStatus(422)
            ->assertJsonPath('ok', false);
    }

    /**
     * A turn that fails must still appear in the history.
     *
     * The package writes the transcript only on success, so a provider outage
     * used to erase both the question and the failure: the user saw an error,
     * reloaded, and found no trace of either.
     */
    public function test_a_failed_turn_is_still_recorded_in_the_transcript(): void
    {
        $conversation = $this->ownedConversation('conv-chat-0004');

        MarketingAgent::fake(fn () => throw new \RuntimeException('Could not connect to AI provider [openai].'));

        $this->postJson(route('chat.send'), [
            'conversation' => $conversation,
            'message' => 'generate the landing page',
        ])->assertOk();

        $stored = DB::table('agent_conversation_messages')
            ->where('conversation_id', $conversation)
            ->orderBy('id')
            ->pluck('content', 'role');

        $this->assertCount(2, $stored);
        $this->assertSame('generate the landing page', $stored['user']);
        $this->assertStringContainsString('Could not connect', $stored['assistant']);
    }

    /**
     * A provider's response body is not a message to a media buyer.
     *
     * A 401 arrived whole, four lines of JSON with the error object still in
     * it. What the reader needs is whether to try again or fetch somebody.
     */
    public function test_a_provider_failure_is_reported_in_words(): void
    {
        $conversation = $this->ownedConversation('conv-chat-0010');

        MarketingAgent::fake(fn () => throw new \RuntimeException(
            "HTTP request returned status code 401:\n{\n  \"error\": {\n    \"message\": \"Your IP is not authorized to make this request.\","
        ));

        $reply = $this->postJson(route('chat.send'), [
            'conversation' => $conversation,
            'message' => 'build a campaign',
        ])->assertOk()->json('reply');

        $this->assertStringNotContainsString('{', $reply, 'A JSON body reached the user.');
        $this->assertStringContainsString('administrator', $reply);
    }

    /** A busy provider is worth retrying, and the reply should say so. */
    public function test_an_overloaded_provider_is_reported_as_temporary(): void
    {
        $conversation = $this->ownedConversation('conv-chat-0011');

        MarketingAgent::fake(fn () => throw new \RuntimeException('AI provider [gemini] is overloaded.'));

        $reply = $this->postJson(route('chat.send'), [
            'conversation' => $conversation,
            'message' => 'build a campaign',
        ])->assertOk()->json('reply');

        $this->assertStringContainsString('busy', $reply);
        $this->assertStringContainsString('again', $reply);
    }

    /**
     * The failure message must not claim more than it knows.
     *
     * It said "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.
     */
    public function test_a_failure_after_a_change_does_not_claim_nothing_changed(): void
    {
        $conversation = $this->ownedConversation('conv-chat-0012');

        $campaign = Campaign::create([
            'user_id' => $this->user->id,
            'name' => 'Winter Sale',
            'platform' => 'meta',
            'status' => 'draft',
        ]);

        app(Workspace::class)->bindTo($conversation);
        app(Workspace::class)->produced($campaign);
        app(Workspace::class)->focusOn($campaign);

        // The turn changes the campaign, then the provider dies.
        MarketingAgent::fake(function () use ($campaign) {
            $campaign->forceFill(['budget' => 30, 'updated_at' => now()->addMinute()])->save();

            throw new \RuntimeException('AI provider [gemini] is overloaded.');
        });

        $reply = $this->postJson(route('chat.send'), [
            'conversation' => $conversation,
            'message' => 'make it thirty a day',
        ])->assertOk()->json('reply');

        $this->assertStringNotContainsString('Nothing in the campaign was changed', $reply);
        $this->assertStringContainsString('applied before the failure', $reply);
    }

    /**
     * A run paused for approval must survive a reload.
     *
     * The prompt was only ever drawn from a JSON response, so refreshing the
     * page left the run paused on the server with nothing on screen able to
     * resolve it, and the campaign stuck half decided.
     */
    public function test_a_pending_approval_is_restored_after_a_reload(): void
    {
        $conversation = $this->ownedConversation('conv-chat-0005');

        DB::table('agent_conversation_messages')->insert([
            'id' => (string) Str::uuid7(),
            'conversation_id' => $conversation,
            'participant_type' => $this->user->getMorphClass(),
            'participant_id' => $this->user->getKey(),
            'agent' => MarketingAgent::class,
            'role' => 'assistant',
            'content' => 'Ready to publish.',
            'attachments' => '[]',
            'tool_calls' => json_encode([['id' => 'call_1', 'name' => 'campaign__publish_campaign', 'arguments' => []]]),
            'tool_results' => '[]',
            'usage' => '[]',
            'meta' => '[]',
            'approval_state' => json_encode(['pending' => ['call_1' => 'This creates the campaign on the platform.']]),
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        $this->get(route('chat.show', ['conversation' => $conversation]))
            ->assertOk()
            ->assertSee('call_1', escape: false)
            ->assertSee('campaign  publish campaign', escape: false);
    }

    /** A decided approval must not come back on the next load. */
    public function test_a_resolved_approval_is_not_restored(): void
    {
        $conversation = $this->ownedConversation('conv-chat-0006');

        DB::table('agent_conversation_messages')->insert([
            'id' => (string) Str::uuid7(),
            'conversation_id' => $conversation,
            'participant_type' => $this->user->getMorphClass(),
            'participant_id' => $this->user->getKey(),
            'agent' => MarketingAgent::class,
            'role' => 'assistant',
            'content' => 'Published.',
            'attachments' => '[]',
            'tool_calls' => '[]',
            'tool_results' => '[]',
            'usage' => '[]',
            'meta' => '[]',
            'approval_state' => json_encode(['pending' => []]),
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        $this->get(route('chat.show', ['conversation' => $conversation]))
            ->assertOk()
            ->assertDontSee('Approve:', escape: false);
    }

    public function test_oversized_and_unsupported_uploads_are_rejected(): void
    {
        $this->postJson(route('chat.send'), [
            'message' => 'here you go',
            'files' => [UploadedFile::fake()->create('payload.php', 10, 'application/x-php')],
        ])->assertStatus(422)->assertJsonValidationErrors('files.0');
    }

    public function test_asking_for_campaign_in_multiple_platforms_offers_all_platforms_in_next_step(): void
    {
        $response = $this->postJson(route('chat.send'), [
            'message' => 'Create a new ad campaign in any platform',
        ])->assertOk();

        $data = $response->json();
        $this->assertNotEmpty($data['choices'], 'Choices should be offered in response');
        $this->assertSame('platforms', $data['choices'][0]['field']);
        $this->assertTrue($data['choices'][0]['multiple']);

        $platforms = array_column($data['choices'][0]['options'], 'value');
        $this->assertContains('meta', $platforms);
        $this->assertContains('google', $platforms);
        $this->assertContains('linkedin', $platforms);
    }
}
