<?php

namespace Tests\Feature;

use App\Agent\Choices;
use App\Agent\MarketingAgent;
use App\Agent\ToolRegistry;
use App\Agent\Workspace;
use App\Models\Campaign;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Laravel\Ai\Tools\Request;
use Tests\SeedsVerifiedCountries;
use Tests\TestCase;

/**
 * Options the user can click instead of typing.
 *
 * Every step used to be an open question, so the user had to know that Meta
 * calls it OUTCOME_LEADS. A tool can now offer what the platform supports, the
 * page draws it as buttons, and clicking sends an ordinary message so the
 * answer takes the same path through the agent as typing would.
 *
 * The rule these protect: options come from the platform's own rules, never
 * from the model, so a button can never offer something that would be rejected.
 */
class ChoicesTest extends TestCase
{
    use RefreshDatabase;
    use SeedsVerifiedCountries;

    protected User $user;

    protected Campaign $campaign;

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

        $this->seedVerifiedCountries();

        MarketingAgent::fake(['Pick one.']);

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

        $workspace = app(Workspace::class);
        $workspace->bindTo('conv-choices-0001');
        $workspace->ownedBy($this->user);

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

        $workspace->focusOn($this->campaign);
    }

    private function runTool(string $name, array $arguments = []): string
    {
        $tool = collect(app(ToolRegistry::class)->resolve())->first(fn ($t) => $t->name() === $name);

        $this->assertNotNull($tool, "Tool {$name} is not registered.");

        return (string) $tool->handle(new Request($arguments));
    }

    public function test_calling_with_no_objective_offers_the_supported_ones(): void
    {
        $this->runTool('campaign__choose_objective');

        $offered = app(Choices::class)->all();

        $this->assertCount(1, $offered);
        $this->assertSame('objective', $offered[0]['field']);
        $this->assertSame(
            ['OUTCOME_SALES', 'OUTCOME_LEADS', 'OUTCOME_TRAFFIC', 'OUTCOME_ENGAGEMENT', 'OUTCOME_AWARENESS'],
            array_column($offered[0]['options'], 'value'),
        );

        // App promotion is the sixth ODAX objective and is deliberately absent:
        // it needs a promoted app object and there is no field for one, so it
        // would be a button that cannot reach a publishable ad set.
        $this->assertNotContains('OUTCOME_APP_PROMOTION', array_column($offered[0]['options'], 'value'));
    }

    /** A button has to read as something a media buyer says, not an API enum. */
    public function test_the_options_are_labelled_for_a_person(): void
    {
        $this->runTool('campaign__choose_objective');

        $options = collect(app(Choices::class)->all()[0]['options'])->keyBy('value');

        $this->assertSame('Leads', $options['OUTCOME_LEADS']['label']);
        $this->assertStringContainsString('pixel', (string) $options['OUTCOME_LEADS']['hint']);
    }

    public function test_business_objectives_use_the_requested_user_facing_labels(): void
    {
        $this->runTool('campaign__choose_objective');

        $options = collect(app(Choices::class)->all()[0]['options'])->keyBy('value');

        $this->assertSame('Sales', $options['OUTCOME_SALES']['label']);
        $this->assertSame('Leads', $options['OUTCOME_LEADS']['label']);
        $this->assertSame('Website Traffic', $options['OUTCOME_TRAFFIC']['label']);
        $this->assertSame('Brand Awareness', $options['OUTCOME_AWARENESS']['label']);
        $this->assertSame('Other', $options['OUTCOME_ENGAGEMENT']['label']);
    }

    /** Clicking sends a message, so there is one way into the agent, not two. */
    public function test_each_option_carries_the_message_a_click_sends(): void
    {
        $this->runTool('campaign__choose_objective');

        $options = collect(app(Choices::class)->all()[0]['options'])->keyBy('value');

        $this->assertSame('Set the objective to OUTCOME_LEADS', $options['OUTCOME_LEADS']['sends']);
    }

    public function test_choosing_sets_the_objective_and_offers_nothing(): void
    {
        $this->runTool('campaign__choose_objective', ['objective' => 'OUTCOME_LEADS']);

        $this->assertSame('OUTCOME_LEADS', $this->campaign->fresh()->objective);
        $this->assertSame([], app(Choices::class)->all());
    }

    /** The options are the platform's, so nothing else can be set through them. */
    public function test_an_objective_the_platform_does_not_support_is_refused(): void
    {
        $result = $this->runTool('campaign__choose_objective', ['objective' => 'OUTCOME_APP_PROMOTION']);

        $this->assertStringContainsString('does not support', $result);
        $this->assertNull($this->campaign->fresh()->objective);
    }

    /**
     * An objective that needs no pixel must not ask for one.
     *
     * needsPixel used to be "anything except traffic", which was true while
     * traffic was the only non-conversion objective and became wrong the moment
     * engagement and awareness existed.
     */
    public function test_the_new_objectives_do_not_demand_a_pixel(): void
    {
        foreach (['OUTCOME_ENGAGEMENT', 'OUTCOME_AWARENESS'] as $objective) {
            $result = json_decode($this->runTool('campaign__choose_objective', ['objective' => $objective]), true);

            $this->assertTrue($result['ok'], "{$objective} should be settable.");
            $this->assertFalse($result['requires_pixel'], "{$objective} does not use a pixel.");
        }
    }

    public function test_the_choice_is_recorded_as_something_the_user_said(): void
    {
        $this->runTool('campaign__choose_objective', ['objective' => 'OUTCOME_SALES']);

        $sources = app(Workspace::class)->sourcesFor($this->campaign->fresh());

        $this->assertSame('user_stated', $sources['objective'] ?? null);
    }

    /** The page needs them in the response, or there is nothing to draw. */
    public function test_offered_choices_reach_the_browser(): void
    {
        DB::table('agent_conversations')->insert([
            'id' => 'conv-choices-0002',
            'participant_type' => $this->user->getMorphClass(),
            'participant_id' => $this->user->getKey(),
            'title' => 'Winter Sale',
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        app(Choices::class)->offer('objective', 'Pick one', [
            ['value' => 'OUTCOME_LEADS', 'label' => 'Leads'],
        ]);

        $this->postJson(route('chat.send'), [
            'conversation' => 'conv-choices-0002',
            'message' => 'hello',
        ])->assertOk()->assertJsonPath('choices.0.options.0.label', 'Leads');
    }

    /** The legal declaration is offered, not asked in prose. */
    public function test_the_special_ad_category_is_offered_as_options(): void
    {
        $this->runTool('campaign__declare_ad_category');

        $offered = collect(app(Choices::class)->all())->firstWhere('field', 'special_ad_categories');

        $this->assertNotNull($offered, 'The category question should be offered.');
        $this->assertSame(
            ['NONE', 'HOUSING', 'EMPLOYMENT', 'CREDIT', 'ISSUES_ELECTIONS_POLITICS'],
            array_column($offered['options'], 'value'),
        );

        // Nothing is declared by merely being shown the question.
        $this->assertNull($this->campaign->fresh()->special_ad_categories);
    }

    public function test_the_category_options_read_as_english(): void
    {
        $this->runTool('campaign__declare_ad_category');

        $options = collect(app(Choices::class)->all())
            ->firstWhere('field', 'special_ad_categories')['options'];
        $byValue = collect($options)->keyBy('value');

        $this->assertSame('None of these', $byValue['NONE']['label']);
        $this->assertSame('None of those categories apply', $byValue['NONE']['sends']);
        $this->assertStringContainsString('Loans', (string) $byValue['CREDIT']['hint']);
    }

    /**
     * Bid strategies read as decisions, not as Meta's constants.
     *
     * LOWEST_COST_WITHOUT_CAP on a button asks the reader to already know the
     * answer, which is the thing offering options was meant to remove.
     */
    public function test_bid_strategies_are_offered_in_words_a_buyer_uses(): void
    {
        $this->runTool('campaign__set_bidding');

        $offered = collect(app(Choices::class)->all())->firstWhere('field', 'bid_strategy');

        $this->assertNotNull($offered, 'The bid strategies should be offered.');

        $byValue = collect($offered['options'])->keyBy('value');

        $this->assertSame('Highest volume', $byValue['LOWEST_COST_WITHOUT_CAP']['label']);
        $this->assertSame('Cost per result goal', $byValue['COST_CAP']['label']);
        $this->assertStringContainsString('average cost', (string) $byValue['COST_CAP']['hint']);

        // Being shown the question settles nothing.
        $this->assertNull($this->campaign->fresh()->bid_strategy);
    }

    /**
     * Devices were accepted everywhere except in the asking.
     *
     * AdSetSpec has always sent device_platforms and set_targeting has always
     * taken them; nothing ever put the question in front of anyone, so every
     * campaign ran on both whatever the landing page was built for.
     */
    public function test_devices_are_offered_alongside_countries_and_placements(): void
    {
        $this->runTool('campaign__set_targeting');

        $offered = collect(app(Choices::class)->all())->firstWhere('field', 'devices');

        $this->assertNotNull($offered, 'Devices should be offered.');
        $this->assertSame(
            ['All devices', 'Mobile only', 'Desktop only'],
            array_column($offered['options'], 'label'),
        );
        $this->assertSame('Suggested', $offered['options'][0]['badge']);
    }

    /** Already answered, so not asked again. */
    public function test_devices_are_not_offered_once_set(): void
    {
        $this->runTool('campaign__set_targeting', [
            'countries' => ['US'],
            'placements' => ['facebook'],
            'devices' => ['mobile'],
        ]);

        $this->assertNull(collect(app(Choices::class)->all())->firstWhere('field', 'devices'));
        $this->assertSame(['mobile'], $this->campaign->fresh()->audience['devices']);
    }

    /**
     * End dates are offered as lengths, because that is how they are decided.
     *
     * Two of the five are named after what they are for rather than how long
     * they are, which SchedulePresetsTest covers. The set and its order are
     * still pinned here: naming two of them was not meant to add, drop or
     * reorder any.
     */
    public function test_end_dates_are_offered_as_lengths_of_time(): void
    {
        $this->campaign->update(['budget_mode' => 'lifetime']);

        $this->runTool('campaign__set_schedule');

        $offered = collect(app(Choices::class)->all())->firstWhere('field', 'ends_at');

        $this->assertNotNull($offered, 'End dates should be offered.');
        $this->assertSame(
            ['7 days', '14-day test', '30-day flight', '60 days', '90 days'],
            array_column($offered['options'], 'label'),
        );

        // The value is a real date, so nobody has to type one.
        $this->assertNotFalse(strtotime($offered['options'][0]['value']));
        $this->assertNull($this->campaign->fresh()->ends_at);
    }

    /**
     * Clearing a date must stay a clear.
     *
     * The offering branch keys off whether the argument was passed at all, not
     * whether it is empty, because passing null is how a date is removed.
     */
    public function test_passing_an_empty_end_date_clears_it_rather_than_asking_again(): void
    {
        $this->campaign->update(['ends_at' => now()->addDays(7)]);

        $this->runTool('campaign__set_schedule', ['ends_at' => null]);

        $this->assertNull($this->campaign->fresh()->ends_at);
        $this->assertNull(collect(app(Choices::class)->all())->firstWhere('field', 'ends_at'));
    }

    /**
     * Multi-select is honoured rather than merely declared.
     *
     * The flag was in the contract and the renderer submitted on first click,
     * so anything offering it would have silently behaved as single-select.
     */
    public function test_multiple_is_carried_through_to_the_browser(): void
    {
        app(Choices::class)->offer(
            field: 'placements',
            question: 'Where should it run?',
            options: [
                ['value' => 'facebook', 'label' => 'Facebook'],
                ['value' => 'instagram', 'label' => 'Instagram'],
            ],
            multiple: true,
        );

        $offered = app(Choices::class)->all()[0];

        $this->assertTrue($offered['multiple']);
    }

    /**
     * A reload must not lose the buttons.
     *
     * Choices were built from the JSON response and never stored, so opening
     * the conversation again left the question unanswered with nothing on
     * screen to answer it. Exactly what happened to paused approvals.
     */
    public function test_offered_choices_survive_a_reload(): void
    {
        DB::table('agent_conversations')->insert([
            'id' => 'conv-choices-0003',
            'participant_type' => $this->user->getMorphClass(),
            'participant_id' => $this->user->getKey(),
            'title' => 'Winter Sale',
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        app(Workspace::class)->bindTo('conv-choices-0003');
        app(Choices::class)->offer('objective', 'Pick one', [
            ['value' => 'OUTCOME_LEADS', 'label' => 'Leads'],
        ]);

        $this->postJson(route('chat.send'), [
            'conversation' => 'conv-choices-0003',
            'message' => 'hello',
        ])->assertOk();

        $this->get(route('chat.show', ['conversation' => 'conv-choices-0003']))
            ->assertOk()
            ->assertSee('OUTCOME_LEADS', escape: false);
    }

    /** And a turn that offers nothing clears what was on screen. */
    public function test_a_turn_offering_nothing_clears_the_old_options(): void
    {
        DB::table('agent_conversations')->insert([
            'id' => 'conv-choices-0004',
            'participant_type' => $this->user->getMorphClass(),
            'participant_id' => $this->user->getKey(),
            'title' => 'Winter Sale',
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        $workspace = app(Workspace::class);
        $workspace->bindTo('conv-choices-0004');
        $workspace->rememberChoices([['field' => 'objective', 'question' => 'Old', 'multiple' => false, 'options' => []]]);

        $this->postJson(route('chat.send'), [
            'conversation' => 'conv-choices-0004',
            'message' => 'hello',
        ])->assertOk()->assertJsonPath('choices', []);

        $this->assertSame([], $workspace->pendingChoices());
    }

    /** Drained, so an answered question cannot reappear on the next turn. */
    public function test_choices_are_not_offered_twice(): void
    {
        $choices = app(Choices::class);

        $choices->offer('objective', 'Pick one', [['value' => 'X', 'label' => 'X']]);

        $this->assertCount(1, $choices->drain());
        $this->assertSame([], $choices->drain());
    }

    /**
     * The same question twice in one turn is one question.
     *
     * Two tools can reach the same field, and the model can call one of them
     * twice. The renderer counts answers by field, so four questions over three
     * fields left "Answer all 4 to continue" permanently disabled: every option
     * was picked and the button never enabled. Seen in the browser on 16 Sep,
     * with devices offered twice alongside placements and click tracking.
     */
    public function test_the_same_field_offered_twice_is_offered_once(): void
    {
        $choices = app(Choices::class);

        $choices->offer('devices', 'Which devices?', [['value' => 'mobile', 'label' => 'Mobile only']]);
        $choices->offer('placements', 'Where?', [['value' => 'facebook', 'label' => 'Facebook']]);
        $choices->offer('devices', 'Which devices?', [['value' => 'mobile,desktop', 'label' => 'All devices']]);

        $offered = $choices->all();

        $this->assertCount(2, $offered);
        $this->assertSame(
            ['devices', 'placements'],
            collect($offered)->pluck('field')->sort()->values()->all(),
        );
    }

    /** The later offer wins, being built from more recent state. */
    public function test_the_second_offer_replaces_the_first(): void
    {
        $choices = app(Choices::class);

        $choices->offer('devices', 'Which devices?', [['value' => 'mobile', 'label' => 'Mobile only']]);
        $choices->offer('devices', 'Which devices?', [['value' => 'mobile,desktop', 'label' => 'All devices']]);

        $devices = collect($choices->all())->firstWhere('field', 'devices');

        $this->assertSame('All devices', $devices['options'][0]['label']);
    }
}
