<?php

namespace Tests\Feature;

use App\Agent\Choices;
use App\Agent\ToolRegistry;
use App\Agent\Workspace;
use App\Campaigns\PublishGate;
use App\Models\ArtifactFieldSource;
use App\Models\Campaign;
use App\Models\User;
use App\Services\Meta\AdSetSpec;
use App\Services\Meta\CampaignSpec;
use App\Services\Meta\GraphApi;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Laravel\Ai\Tools\Request;
use Tests\TestCase;

/**
 * Where the budget sits, and what kind of budget it is.
 *
 * There was one budget and it always meant the same thing: an amount per day,
 * on the ad set. SERP-1833 names both of Meta's axes. Advantage campaign budget
 * moves the money to the campaign and lets Meta split it; a lifetime budget is
 * a total rather than a rate, which changes what the floor and the ceiling even
 * mean and makes an end date compulsory.
 *
 * The cases worth holding are the ones where getting it wrong is quiet: a
 * lifetime total read as a daily rate, and a budget sent to the object that is
 * not holding it, which Meta accepts and ignores.
 */
class BudgetStrategyTest extends TestCase
{
    use RefreshDatabase;

    protected Campaign $campaign;

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

        config([
            'platforms.guardrails.allowed_ad_accounts' => ['act_777'],
            'platforms.guardrails.max_daily_budget' => 50,
            'platforms.meta.mcp.enabled' => false,
            'platforms.meta.token' => 'test-token',
        ]);

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

        $workspace = app(Workspace::class);
        $workspace->bindTo('conv-budget-001');

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

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

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

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

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

    private function offered(string $field): ?array
    {
        return collect(app(Choices::class)->all())->firstWhere('field', $field);
    }

    // ------------------------------------------------------------- the choice

    /** Nobody should have to know the words CBO and ABO to answer this. */
    public function test_the_arrangement_is_offered_as_something_to_click(): void
    {
        $this->runTool('campaign__set_campaign_budget');

        $strategy = $this->offered('budget_strategy');

        $this->assertNotNull($strategy, 'How the budget works should be on screen.');
        $this->assertSame(
            ['adset:daily', 'adset:lifetime', 'campaign:daily', 'campaign:lifetime'],
            array_column($strategy['options'], 'value'),
        );

        // And the amount, so the whole question is one screen rather than two turns.
        $this->assertNotNull($this->offered('budget_amount'));
    }

    /** Asking again about something already answered is the thing options were meant to stop. */
    public function test_the_arrangement_is_not_offered_again_once_chosen(): void
    {
        $this->runTool('campaign__set_campaign_budget', ['mode' => 'daily', 'level' => 'adset']);

        $this->assertNull($this->offered('budget_strategy'));
        $this->assertNotNull($this->offered('budget_amount'), 'The amount is still outstanding.');
    }

    /** A lifetime budget of 20 is not an amount anyone means. */
    public function test_lifetime_amounts_are_offered_as_totals_not_daily_rungs(): void
    {
        $this->runTool('campaign__set_campaign_budget', ['mode' => 'lifetime']);

        $amounts = array_column($this->offered('budget_amount')['options'], 'value');

        $this->assertSame(['100', '250', '500', '1000', '2500'], $amounts);
    }

    public function test_an_unrecognised_arrangement_is_refused_rather_than_stored(): void
    {
        $result = $this->runTool('campaign__set_campaign_budget', ['mode' => 'weekly', 'amount' => 20]);

        $this->assertFalse($result['ok']);
        $this->assertNull($this->campaign->fresh()->budget_mode);
        $this->assertNull($this->campaign->fresh()->budget, 'Nothing should have been set.');
    }

    /**
     * Null is "nobody was asked", and has to keep meaning the ordinary
     * arrangement everything published so far used.
     */
    public function test_an_unanswered_campaign_is_a_daily_ad_set_budget(): void
    {
        $this->assertNull($this->campaign->budget_level);
        $this->assertSame('adset', $this->campaign->budgetLevel());
        $this->assertSame('daily', $this->campaign->budgetMode());
        $this->assertFalse($this->campaign->usesCampaignBudget());
    }

    // -------------------------------------------------------------- the gate

    /**
     * A generated name must not block publishing.
     *
     * StartCampaign records a name nobody chose as a fallback so the panel can
     * show that. The gate then refused to publish until it was "confirmed",
     * while the agent is told never to ask for a campaign name, so the guided
     * flow reached a step that no click could clear. A name cannot spend money.
     */
    public function test_a_generated_name_does_not_block_publishing(): void
    {
        app(Workspace::class)->recordSource($this->campaign, 'name', ArtifactFieldSource::FALLBACK);

        $problems = app(PublishGate::class)->check($this->campaign->fresh());

        $this->assertEmpty(array_filter($problems, fn ($p) => str_contains($p, 'name was guessed')));
    }

    /** Anything that can spend is still held back. */
    public function test_a_guessed_country_still_blocks_publishing(): void
    {
        app(Workspace::class)->recordSource($this->campaign, 'locations', ArtifactFieldSource::FALLBACK);

        $problems = app(PublishGate::class)->check($this->campaign->fresh());

        $this->assertNotEmpty(array_filter($problems, fn ($p) => str_contains($p, 'was guessed')));
    }

    /** Meta refuses this, but only after the campaign exists. */
    public function test_a_lifetime_budget_without_an_end_date_is_refused(): void
    {
        $this->campaign->update(['budget_mode' => 'lifetime', 'budget' => 500]);

        $problems = app(PublishGate::class)->check($this->campaign->fresh());

        $this->assertNotEmpty(array_filter($problems, fn ($p) => str_contains($p, 'needs an end date')));
    }

    /**
     * The ceiling is a daily one, so a lifetime total has to be read by the day
     * it works out to. Otherwise every real lifetime budget is refused.
     */
    public function test_a_lifetime_budget_is_measured_by_what_it_spends_a_day(): void
    {
        $this->campaign->update([
            'budget_mode' => 'lifetime',
            'budget' => 300,               // 10 a day over 30 days, under the 50 ceiling
            'ends_at' => now()->addDays(30),
        ]);

        $problems = app(PublishGate::class)->check($this->campaign->fresh());

        $this->assertEmpty(array_filter($problems, fn ($p) => str_contains($p, 'budget')));
    }

    public function test_a_lifetime_budget_spending_too_fast_is_still_refused(): void
    {
        $this->campaign->update([
            'budget_mode' => 'lifetime',
            'budget' => 3000,              // 1000 a day over 3 days
            'ends_at' => now()->addDays(3),
        ]);

        $problems = app(PublishGate::class)->check($this->campaign->fresh());

        $this->assertNotEmpty(array_filter($problems, fn ($p) => str_contains($p, 'above the')));
    }

    /** The daily ceiling still applies unchanged to a daily budget. */
    public function test_a_daily_budget_over_the_ceiling_is_refused_at_the_tool(): void
    {
        $result = $this->runTool('campaign__set_campaign_budget', ['amount' => 500]);

        $this->assertFalse($result['ok']);
        $this->assertNull($this->campaign->fresh()->budget);
    }

    /**
     * A lifetime total is not checked against the daily ceiling in the tool,
     * because without an end date there is nothing to divide by. The gate does
     * it once the dates are known.
     */
    public function test_a_lifetime_total_over_the_daily_ceiling_is_accepted_by_the_tool(): void
    {
        $result = $this->runTool('campaign__set_campaign_budget', ['mode' => 'lifetime', 'amount' => 500]);

        $this->assertTrue($result['ok']);
        $this->assertSame('500.00', (string) $this->campaign->fresh()->budget);
        $this->assertSame('a lifetime budget needs an end date', $result['still_needed'] ?? null);
    }

    // ---------------------------------------------------------- what is sent

    /** Advantage campaign budget puts the money and the strategy on the campaign. */
    public function test_a_campaign_budget_is_sent_on_the_campaign(): void
    {
        Http::fake(['*' => Http::response(['id' => 'camp-1'])]);

        app(GraphApi::class)->createCampaign(new CampaignSpec(
            adAccountId: 'act_777',
            name: 'Winter Sale',
            objective: 'OUTCOME_TRAFFIC',
            budgetLevel: 'campaign',
            budgetMinorUnits: 2500,
            bidStrategy: 'LOWEST_COST_WITHOUT_CAP',
        ));

        Http::assertSent(function ($request): bool {
            $data = $request->data();

            return str_contains($request->url(), '/campaigns')
                && ($data['daily_budget'] ?? null) === 2500
                && ($data['bid_strategy'] ?? null) === 'LOWEST_COST_WITHOUT_CAP'
                // Not 'true'. Ad set budget sharing is a different feature and
                // Meta refuses it alongside a campaign budget (subcode 4834002).
                && ($data['is_adset_budget_sharing_enabled'] ?? null) === 'false';
        });
    }

    /**
     * And an ad set budget must not appear on the campaign. Meta accepts the
     * extra field and ignores it, so this only shows up as a campaign that
     * spends nothing like what was agreed.
     */
    public function test_an_ad_set_budget_is_not_sent_on_the_campaign(): void
    {
        Http::fake(['*' => Http::response(['id' => 'camp-1'])]);

        app(GraphApi::class)->createCampaign(new CampaignSpec(
            adAccountId: 'act_777',
            name: 'Winter Sale',
            objective: 'OUTCOME_TRAFFIC',
            budgetMinorUnits: 2500,
        ));

        Http::assertSent(function ($request): bool {
            $data = $request->data();

            return str_contains($request->url(), '/campaigns')
                && ! isset($data['daily_budget'])
                && ($data['is_adset_budget_sharing_enabled'] ?? null) === 'false';
        });
    }

    /**
     * The other half: with the money on the campaign, the ad set must not
     * repeat it or the strategy. The cost cap amount does stay here, because it
     * is a per-ad-set target even when Meta decides the split.
     */
    public function test_an_ad_set_under_a_campaign_budget_carries_no_budget_of_its_own(): void
    {
        Http::fake(['*' => Http::response(['id' => 'adset-1'])]);

        app(GraphApi::class)->createAdSet(new AdSetSpec(
            adAccountId: 'act_777',
            campaignId: 'camp-1',
            name: 'Winter Sale - ad set',
            budgetMinorUnits: 2500,
            optimizationGoal: 'LINK_CLICKS',
            targeting: ['countries' => ['US']],
            pageId: '123',
            bidStrategy: 'COST_CAP',
            bidAmountCents: 400,
            budgetLevel: 'campaign',
        ));

        Http::assertSent(function ($request): bool {
            $data = $request->data();

            return str_contains($request->url(), '/adsets')
                && ! isset($data['daily_budget'])
                && ! isset($data['lifetime_budget'])
                && ! isset($data['bid_strategy'])
                && ($data['bid_amount'] ?? null) === 400;
        });
    }

    public function test_an_ad_set_lifetime_budget_is_sent_as_a_lifetime_budget(): void
    {
        Http::fake(['*' => Http::response(['id' => 'adset-1'])]);

        app(GraphApi::class)->createAdSet(new AdSetSpec(
            adAccountId: 'act_777',
            campaignId: 'camp-1',
            name: 'Winter Sale - ad set',
            budgetMinorUnits: 30000,
            optimizationGoal: 'LINK_CLICKS',
            targeting: ['countries' => ['US']],
            pageId: '123',
            endsAt: now()->addDays(30),
            budgetMode: 'lifetime',
        ));

        Http::assertSent(function ($request): bool {
            $data = $request->data();

            return ($data['lifetime_budget'] ?? null) === 30000
                && ! isset($data['daily_budget'])
                && filled($data['end_time'] ?? null);
        });
    }

    /**
     * Reach optimises toward nobody in particular, so there is no promoted
     * object, and Meta rejects one sent against it. An empty promoted_object
     * would be rejected the same way, so the field is omitted rather than sent
     * as "[]".
     */
    public function test_an_awareness_ad_set_sends_no_promoted_object(): void
    {
        Http::fake(['*' => Http::response(['id' => 'adset-1'])]);

        app(GraphApi::class)->createAdSet(new AdSetSpec(
            adAccountId: 'act_777',
            campaignId: 'camp-1',
            name: 'Winter Sale - ad set',
            budgetMinorUnits: 2000,
            optimizationGoal: 'REACH',
            targeting: ['countries' => ['US']],
            pageId: '123',
        ));

        Http::assertSent(fn ($request): bool => ! array_key_exists('promoted_object', $request->data()));
    }

    /** The objectives that do name one still do. */
    public function test_a_traffic_ad_set_still_names_the_page(): void
    {
        Http::fake(['*' => Http::response(['id' => 'adset-1'])]);

        app(GraphApi::class)->createAdSet(new AdSetSpec(
            adAccountId: 'act_777',
            campaignId: 'camp-1',
            name: 'Winter Sale - ad set',
            budgetMinorUnits: 2000,
            optimizationGoal: 'LINK_CLICKS',
            targeting: ['countries' => ['US']],
            pageId: '123',
        ));

        Http::assertSent(fn ($request): bool => json_decode(
            (string) $request->data()['promoted_object'], true,
        ) === ['page_id' => '123']);
    }

    public function test_a_lifetime_campaign_budget_carries_its_end_date(): void
    {
        Http::fake(['*' => Http::response(['id' => 'camp-1'])]);

        app(GraphApi::class)->createCampaign(new CampaignSpec(
            adAccountId: 'act_777',
            name: 'Winter Sale',
            objective: 'OUTCOME_TRAFFIC',
            budgetLevel: 'campaign',
            budgetMode: 'lifetime',
            budgetMinorUnits: 50000,
            endsAt: now()->addDays(30),
        ));

        Http::assertSent(function ($request): bool {
            $data = $request->data();

            return ($data['lifetime_budget'] ?? null) === 50000
                && ! isset($data['daily_budget'])
                && filled($data['stop_time'] ?? null);
        });
    }
}
