<?php

namespace Tests\Feature;

use App\Agent\MarketingAgent;
use App\Http\Controllers\ChatController;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;

/**
 * What was on screen is what a reload shows.
 *
 * The package writes the assistant row with the model's raw text. The
 * controller then builds the reply the browser is sent: an empty turn becomes
 * fallbackText(), and questionFor() substituted the step's canned ask whenever
 * the controller had stepped in. chat.js replaces the streamed bubble with that
 * reply when `done` arrives, and nothing wrote it back.
 *
 * So the screen and the record disagreed from the moment the turn ended. Seen
 * on arb-dev on 25 Sep: the buyer was shown "Choose a daily amount" three turns
 * running while the budget was stuck, and the three replies that explained why
 * 30 was refused, why the even split could not be set, and what the ceiling
 * was, were all written to the database and never shown. Reloading produced a
 * conversation they had never been in.
 *
 * Two records of one exchange is worse than either alone, because nothing says
 * which one the person was answering.
 */
class TheTranscriptIsTheConversationYouHadTest extends TestCase
{
    use RefreshDatabase;

    private function conversation(User $user): string
    {
        $id = (string) Str::uuid7();

        DB::table('agent_conversations')->insert([
            'id' => $id,
            'participant_type' => $user->getMorphClass(),
            'participant_id' => $user->getKey(),
            'title' => 'Budget',
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        return $id;
    }

    private function say(string $conversationId, User $user, string $role, string $content): string
    {
        $id = (string) Str::uuid7();

        DB::table('agent_conversation_messages')->insert([
            'id' => $id,
            'conversation_id' => $conversationId,
            'participant_type' => $user->getMorphClass(),
            'participant_id' => $user->getKey(),
            'agent' => MarketingAgent::class,
            'role' => $role,
            'content' => $content,
            'attachments' => '[]',
            'tool_calls' => '[]',
            'tool_results' => '[]',
            'usage' => '[]',
            'meta' => '[]',
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        return $id;
    }

    private function controller(): ChatController
    {
        return app(ChatController::class);
    }

    /** @param array<string,mixed> $set */
    private function callPrivate(object $target, string $method, array $arguments = []): mixed
    {
        $reflection = new \ReflectionMethod($target, $method);
        $reflection->setAccessible(true);

        return $reflection->invokeArgs($target, $arguments);
    }

    private function setPrivate(object $target, string $property, mixed $value): void
    {
        $reflection = new \ReflectionProperty($target, $property);
        $reflection->setAccessible(true);
        $reflection->setValue($target, $value);
    }

    private function contentOf(string $messageId): ?string
    {
        return DB::table('agent_conversation_messages')->where('id', $messageId)->value('content');
    }

    /** The defect: the displayed reply existed only on screen. */
    public function test_the_displayed_reply_is_written_to_the_transcript(): void
    {
        $user = User::factory()->create();
        $conversation = $this->conversation($user);

        $this->say($conversation, $user, 'user', 'USD 30');
        $before = $this->say($conversation, $user, 'assistant', 'An earlier reply');

        $controller = $this->controller();
        $this->setPrivate($controller, 'latestAssistantId', $before);

        $turn = $this->say($conversation, $user, 'assistant', 'the raw model text');

        $this->callPrivate($controller, 'recordDisplayedReply', [$conversation, 'what the user was shown']);

        $this->assertSame('what the user was shown', $this->contentOf($turn));
    }

    /**
     * The previous turn is not rewritten when this one stored nothing.
     *
     * A turn can legitimately write no row at all, and "update the latest
     * assistant row" would then overwrite the reply before it, replacing a
     * turn the user did see with words from a turn they did not.
     */
    public function test_a_turn_that_stored_nothing_leaves_the_previous_reply_alone(): void
    {
        $user = User::factory()->create();
        $conversation = $this->conversation($user);

        $before = $this->say($conversation, $user, 'assistant', 'An earlier reply');

        $controller = $this->controller();
        $this->setPrivate($controller, 'latestAssistantId', $before);

        $this->callPrivate($controller, 'recordDisplayedReply', [$conversation, 'words from a turn with no row']);

        $this->assertSame('An earlier reply', $this->contentOf($before));
    }

    /**
     * The first message of a new chat is still recorded.
     *
     * There is no earlier row to compare against, and treating that absence as
     * "nothing to do" would skip the one turn that always creates a row.
     */
    public function test_the_first_turn_of_a_new_conversation_is_recorded(): void
    {
        $user = User::factory()->create();
        $conversation = $this->conversation($user);

        $controller = $this->controller();
        $this->setPrivate($controller, 'latestAssistantId', null);

        $turn = $this->say($conversation, $user, 'assistant', 'the raw model text');

        $this->callPrivate($controller, 'recordDisplayedReply', [$conversation, 'what the user was shown']);

        $this->assertSame('what the user was shown', $this->contentOf($turn));
    }

    /** An empty reply is not stored over text that is already there. */
    public function test_an_empty_reply_overwrites_nothing(): void
    {
        $user = User::factory()->create();
        $conversation = $this->conversation($user);

        $controller = $this->controller();
        $this->setPrivate($controller, 'latestAssistantId', null);

        $turn = $this->say($conversation, $user, 'assistant', 'the raw model text');

        $this->callPrivate($controller, 'recordDisplayedReply', [$conversation, '']);

        $this->assertSame('the raw model text', $this->contentOf($turn));
    }

    /**
     * The model's own words survive a step-in.
     *
     * questionFor() returned the step's ask whenever the controller stepped in,
     * which is what produced the same sentence three turns running while the
     * explanation of the refusal was thrown away.
     */
    public function test_the_models_words_are_kept_when_the_controller_steps_in(): void
    {
        $controller = $this->controller();
        $this->setPrivate($controller, 'steppedIn', ['ask' => 'Choose a daily amount.']);

        $reply = $this->callPrivate($controller, 'questionFor', [
            'USD 30 was refused because the ceiling is USD 20.', [], false,
        ]);

        // Kept and led with. The step's question follows it, so the prose next
        // to the buttons still describes the step they belong to; see
        // QuestionMatchesOptionsTest for the defect that rule exists for.
        $this->assertStringStartsWith('USD 30 was refused because the ceiling is USD 20.', $reply);
        $this->assertStringEndsWith('Choose a daily amount.', $reply);
    }

    /** And the step's question still covers a turn that said nothing at all. */
    public function test_the_step_question_still_covers_a_silent_turn(): void
    {
        $controller = $this->controller();
        $this->setPrivate($controller, 'steppedIn', ['ask' => 'Choose a daily amount.']);

        $reply = $this->callPrivate($controller, 'questionFor', ['', [], false]);

        $this->assertSame('Choose a daily amount.', $reply);
    }

    /**
     * End to end, because the unit tests above cannot see the wiring.
     *
     * They call recordDisplayedReply() directly, so deleting the one line in
     * finalizeTurn() that calls it leaves every one of them green. That is the
     * same trap as a test written against a response the code returns before it
     * reaches the branch under test, and it is worth one slower test to close.
     */
    public function test_a_real_turn_stores_what_the_browser_was_sent(): void
    {
        $user = User::factory()->create();
        $this->actingAs($user);

        // An empty turn, because that is when the displayed reply and the
        // stored one actually differ. With the model returning words they
        // already agree, and the test passes whether the write-back exists or
        // not, which is how this was first written.
        MarketingAgent::fake(['']);

        $response = $this->postJson(route('chat.send'), ['message' => 'USD 30'])->assertOk();

        $shown = $response->json('reply');
        $this->assertNotEmpty($shown, 'the browser was sent nothing at all');

        $stored = DB::table('agent_conversation_messages')
            ->where('conversation_id', $response->json('conversation'))
            ->where('role', 'assistant')
            ->orderByDesc('id')
            ->value('content');

        $this->assertSame($shown, $stored, 'the transcript does not match what was displayed');
    }

    /** A failure keeps its own words, which is what says what broke. */
    public function test_a_failed_turn_keeps_its_own_words(): void
    {
        $controller = $this->controller();
        $this->setPrivate($controller, 'steppedIn', ['ask' => 'Choose a daily amount.']);

        $reply = $this->callPrivate($controller, 'questionFor', ['That step failed: no token.', [], true]);

        $this->assertSame('That step failed: no token.', $reply);
    }
}
