<?php

namespace Tests\Feature;

use App\Campaigns\Platforms\GoogleRules;
use App\Campaigns\Publisher;
use App\Campaigns\Publishing\GooglePublisher;
use App\Campaigns\Targeting\TargetingSelections;
use App\Models\Asset;
use App\Models\Brief;
use App\Models\Campaign;
use App\Models\CampaignAd;
use App\Models\Location;
use App\Models\PlatformLocation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Tests\SeedsVerifiedCountries;
use Tests\TestCase;

/**
 * Publishing a Google campaign, from the campaign we hold.
 *
 * The point of this class is that the thing reaching Google is the campaign the
 * buyer actually built: its budget, its bidding, its countries, its copy. Before
 * GooglePublisher, Google's create path read a GoogleAdsDraft filled in by a
 * separate agent in a separate chat, so none of that was true.
 *
 * Nothing here talks to Google. Every call is faked, and the assertions are
 * about the payload that would have been sent.
 */
class GooglePublisherTest extends TestCase
{
    use RefreshDatabase;
    use SeedsVerifiedCountries;

    private User $user;

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

        $this->seedVerifiedCountries();

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

        // One common internal account from config, the same shape as Meta's
        // single access token. Google is not connected per user.
        config([
            'platforms.guardrails.allowed_ad_accounts' => ['1234567890'],
            'platforms.guardrails.max_daily_budget' => 100,
            'services.google_ads.refresh_token' => 'refresh-token',
            'services.google_ads.customer_id' => '123-456-7890',
        ]);
    }

    /** Every Google call answered with a plausible shape, and recorded. */
    private function fakeGoogle(): void
    {
        // The shape a single-resource mutate actually returns: one result
        // carrying the resource name, not one nested under a per-type key.
        $resource = fn (string $path): array => ['results' => [['resourceName' => $path]]];

        Http::fake([
            'oauth2.googleapis.com/*' => Http::response(['access_token' => 'token', 'expires_in' => 3600]),
            '*campaignBudgets:mutate' => Http::response($resource('customers/1234567890/campaignBudgets/111')),
            '*campaigns:mutate' => Http::response($resource('customers/1234567890/campaigns/222')),
            '*adGroups:mutate' => Http::response($resource('customers/1234567890/adGroups/333')),
            '*adGroupAds:mutate' => Http::response($resource('customers/1234567890/adGroupAds/444')),
            '*adGroupCriteria:mutate' => Http::response(['results' => []]),
            '*campaignCriteria:mutate' => Http::response(['results' => []]),
            '*googleAds:searchStream' => Http::response([['results' => []]]),
        ]);
    }

    private function campaign(array $attributes = []): Campaign
    {
        $brief = Brief::create([
            'user_id' => $this->user->id,
            'conversation_id' => 'conv-google-publish',
            'landing_url' => 'https://iblockads.org/',
            'page_analysis' => [
                'url' => 'https://iblockads.org/',
                'title' => 'iBlockAds',
                'description' => 'Block annoying ads.',
                'headings' => ['iBlockAds'],
                'text' => 'Block annoying ads and browse faster.',
                'keywords' => ['ad blocker', 'block ads', 'browse faster'],
            ],
        ]);

        $campaign = Campaign::create([
            'user_id' => $this->user->id,
            'brief_id' => $brief->id,
            'name' => 'iBlockAds Search',
            'platform' => 'google',
            'status' => 'draft',
            'objective' => 'LEADS',
            'ad_account_id' => '1234567890',
            'pixel_id' => 'customers/1234567890/conversionActions/9',
            'landing_url' => 'https://iblockads.org/',
            'budget' => 25,
            'budget_mode' => 'daily',
            'currency' => 'USD',
            'bid_strategy' => 'MAXIMIZE_CONVERSIONS',
            'locations' => ['countries' => ['US', 'GB']],
            'placements' => ['search'],
            'utm' => '',
            ...$attributes,
        ]);

        $asset = Asset::create([
            'user_id' => $this->user->id,
            'kind' => 'image',
            'disk' => 'local',
            'path' => 'a.png',
        ]);

        // Three headlines and two descriptions, which is Google's minimum for a
        // responsive search ad.
        foreach ([
            ['Block Annoying Ads', 'Browse faster on every site.'],
            ['Browse Faster Today', 'Stop trackers and pop ups.'],
            ['Private Browsing Made Easy', 'Browse faster on every site.'],
        ] as [$headline, $description]) {
            CampaignAd::create([
                'campaign_id' => $campaign->id,
                'asset_id' => $asset->id,
                'headline' => $headline,
                'description' => $description,
                'primary_text' => $description,
                'cta' => 'LEARN_MORE',
                'status' => 'draft',
            ]);
        }

        return $campaign->fresh();
    }

    /** The body of the nth call to a mutate endpoint. */
    private function sent(string $endpoint): ?array
    {
        foreach (Http::recorded() as [$request]) {
            /** @var Request $request */
            if (str_contains($request->url(), $endpoint)) {
                return $request->data();
            }
        }

        return null;
    }

    public function test_live_budget_edit_refuses_missing_budget_id_before_google_mutation(): void
    {
        $campaign = $this->campaign([
            'external_campaign_id' => '222',
            'external_ids' => ['campaign_id' => '222'],
            'status' => 'published',
        ]);
        Http::fake();

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('Google budget id is missing');

        try {
            app(GooglePublisher::class)->update($campaign, ['budget' => ['from' => '25', 'to' => '30']], 'tester');
        } finally {
            Http::assertNothingSent();
        }
    }

    public function test_it_creates_a_paused_search_campaign_from_our_campaign(): void
    {
        $this->fakeGoogle();
        $campaign = $this->campaign();

        $decision = app(Publisher::class)->propose($campaign, 'tester');
        $this->assertSame('proposed', $decision->state, 'Blocked: '.$decision->reason);

        app(Publisher::class)->approve($decision, 'tester');

        $campaign->refresh();

        $this->assertSame('published', $campaign->status, (string) $campaign->last_error);
        $this->assertSame('222', $campaign->external_campaign_id);

        $sent = $this->sent('campaigns:mutate')['operations'][0]['create'];

        $this->assertSame('PAUSED', $sent['status'], 'A campaign must never be created live.');
        $this->assertSame('SEARCH', $sent['advertisingChannelType']);
        $this->assertSame('iBlockAds Search', $sent['name']);
    }

    /** The budget that reaches Google is the one on the campaign, in micros. */
    public function test_the_budget_is_sent_as_micros_of_our_daily_amount(): void
    {
        $this->fakeGoogle();

        $decision = app(Publisher::class)->propose($this->campaign(), 'tester');
        app(Publisher::class)->approve($decision, 'tester');

        $budget = $this->sent('campaignBudgets:mutate')['operations'][0]['create'];

        $this->assertSame('25000000', $budget['amountMicros']);
    }

    /**
     * A target strategy carries its target.
     *
     * biddingSettings fell through to maximizeConversions for anything it did
     * not recognise, so TARGET_CPA became "spend it all at any price" and the
     * number the user chose was dropped in silence.
     */
    public function test_a_target_cpa_campaign_sends_the_target(): void
    {
        $this->fakeGoogle();

        $campaign = $this->campaign([
            'bid_strategy' => 'TARGET_CPA',
            'bid_amount' => 12.50,
        ]);

        $decision = app(Publisher::class)->propose($campaign, 'tester');
        $this->assertSame('proposed', $decision->state, 'Blocked: '.$decision->reason);

        app(Publisher::class)->approve($decision, 'tester');

        $sent = $this->sent('campaigns:mutate')['operations'][0]['create'];

        $this->assertArrayHasKey('targetCpa', $sent, 'The strategy was replaced by a different one.');
        $this->assertSame('12500000', $sent['targetCpa']['targetCpaMicros']);
        $this->assertArrayNotHasKey('maximizeConversions', $sent);
    }

    /**
     * Countries reach Google as its own geo ids.
     *
     * Passing our ISO codes made the lookup search for a country literally
     * named "US", find nothing, and skip the criterion, which is a campaign
     * targeting the entire world with no error anywhere.
     */
    public function test_countries_are_sent_as_google_geo_targets(): void
    {
        $this->fakeGoogle();

        $decision = app(Publisher::class)->propose($this->campaign(), 'tester');
        app(Publisher::class)->approve($decision, 'tester');

        $criteria = $this->sent('campaignCriteria:mutate')['operations'] ?? [];
        $targets = array_map(fn (array $op): string => $op['create']['location']['geoTargetConstant'], $criteria);

        $this->assertContains('geoTargetConstants/2840', $targets, 'US was not targeted.');
        $this->assertContains('geoTargetConstants/2826', $targets, 'GB was not targeted.');
    }

    public function test_selected_google_city_replaces_its_whole_country_criterion(): void
    {
        $this->fakeGoogle();
        $campaign = $this->campaign();
        $mapping = $this->locationMapping('geoTargetConstants/123');
        app(TargetingSelections::class)->addCampaignLocation($campaign, $mapping);

        $panel = view('partials.campaign-panel', [
            'campaign' => $campaign->fresh(),
            'progress' => [],
            'nextStep' => null,
            'blocking' => [],
            'sources' => [],
            'pending' => [],
            'needsRebuild' => [],
            'campaigns' => collect([$campaign]),
            'assets' => collect(),
        ])->render();
        $this->assertStringContainsString('US (selected places only)', $panel);

        $publisher = app(Publisher::class);
        $decision = $publisher->propose($campaign->fresh(), 'tester');
        $this->assertSame(['countries' => ['GB'], 'geo_targets' => ['geoTargetConstants/123']], $decision->params['google_targeting']['locations']);
        $this->assertTrue($publisher->approve($decision, 'tester')->wasApplied());

        $criteria = $this->sent('campaignCriteria:mutate')['operations'] ?? [];
        $targets = array_column(array_column($criteria, 'create'), 'location');
        $resources = array_column($targets, 'geoTargetConstant');

        $this->assertContains('geoTargetConstants/123', $resources);
        $this->assertContains('geoTargetConstants/2826', $resources);
        $this->assertNotContains('geoTargetConstants/2840', $resources);
    }

    public function test_stale_google_city_blocks_publish_before_any_mutation(): void
    {
        $this->fakeGoogle();
        $campaign = $this->campaign();
        $mapping = $this->locationMapping('geoTargetConstants/123');
        app(TargetingSelections::class)->addCampaignLocation($campaign, $mapping);
        $mapping->update(['verified_at' => now()->subDays(31)]);

        $decision = app(Publisher::class)->propose($campaign->fresh(), 'tester');

        $this->assertSame('blocked', $decision->state);
        $this->assertStringContainsString('out of date', $decision->reason);
        Http::assertNotSent(fn (Request $request): bool => str_contains($request->url(), ':mutate'));
    }

    public function test_existing_google_campaign_is_not_blocked_by_an_expired_local_lookup(): void
    {
        $campaign = $this->campaign();
        $mapping = $this->locationMapping('geoTargetConstants/123');
        $selections = app(TargetingSelections::class);
        $selections->addCampaignLocation($campaign, $mapping);
        $campaign->update(['external_campaign_id' => 'live-123', 'status' => 'published']);
        $mapping->update(['verified_at' => now()->subDays(31)]);

        $this->assertSame([], $selections->locationProblems($campaign->fresh()));
    }

    public function test_removing_a_country_does_not_publish_its_old_city_selection(): void
    {
        $this->fakeGoogle();
        $campaign = $this->campaign();
        $selections = app(TargetingSelections::class);
        $selections->addCampaignLocation($campaign, $this->locationMapping('geoTargetConstants/123'));
        $selections->saveCampaignCountries($campaign->fresh(), ['GB']);

        $decision = app(Publisher::class)->propose($campaign->fresh(), 'tester');

        $this->assertSame('blocked', $decision->state);
        $this->assertStringContainsString('invalid', $decision->reason);
        Http::assertNotSent(fn (Request $request): bool => str_contains($request->url(), ':mutate'));
    }

    public function test_selected_google_state_uses_its_region_criterion(): void
    {
        $this->fakeGoogle();
        $campaign = $this->campaign();
        app(TargetingSelections::class)->addCampaignLocation($campaign, $this->locationMapping('geoTargetConstants/21167', 'region', 'New York'));

        $publisher = app(Publisher::class);
        $decision = $publisher->propose($campaign->fresh(), 'tester');
        $this->assertTrue($publisher->approve($decision, 'tester')->wasApplied());

        $criteria = $this->sent('campaignCriteria:mutate')['operations'] ?? [];
        $resources = array_column(array_column(array_column($criteria, 'create'), 'location'), 'geoTargetConstant');

        $this->assertContains('geoTargetConstants/21167', $resources);
        $this->assertNotContains('geoTargetConstants/2840', $resources);
    }

    public function test_google_location_change_after_review_requires_a_new_approval(): void
    {
        $this->fakeGoogle();
        $campaign = $this->campaign();
        $publisher = app(Publisher::class);
        $decision = $publisher->propose($campaign, 'tester');

        app(TargetingSelections::class)->addCampaignLocation($campaign, $this->locationMapping('geoTargetConstants/123'));

        $applied = $publisher->approve($decision, 'tester');

        $this->assertFalse($applied->wasApplied());
        $this->assertStringContainsString('changed since review', $applied->reason);
        Http::assertNotSent(fn (Request $request): bool => str_contains($request->url(), ':mutate'));
    }

    private function locationMapping(string $resourceName, string $type = 'city', string $name = 'Springfield'): PlatformLocation
    {
        $city = Location::factory()->create([
            'type' => $type,
            'name' => $name,
            'country_code' => 'US',
            'iso_code' => null,
            'source' => 'google',
            'source_key' => $resourceName,
        ]);

        return PlatformLocation::factory()->create([
            'location_id' => $city->getKey(),
            'platform' => 'google',
            'external_id' => $resourceName,
            'provider_name' => $name.', United States',
        ]);
    }

    /** The copy written in chat becomes the responsive search ad's variations. */
    public function test_the_ad_carries_our_headlines_and_descriptions(): void
    {
        $this->fakeGoogle();

        $decision = app(Publisher::class)->propose($this->campaign(), 'tester');
        app(Publisher::class)->approve($decision, 'tester');

        $ad = $this->sent('adGroupAds:mutate')['operations'][0]['create'];
        $headlines = array_column($ad['ad']['responsiveSearchAd']['headlines'], 'text');

        $this->assertContains('Block Annoying Ads', $headlines);
        $this->assertCount(3, $headlines);
        $this->assertSame('PAUSED', $ad['status']);
        $this->assertSame('https://iblockads.org/', $ad['ad']['finalUrls'][0]);
    }

    /** Keywords come from the landing page, which is the only place they exist. */
    public function test_keywords_come_from_the_landing_page_analysis(): void
    {
        $this->fakeGoogle();

        $decision = app(Publisher::class)->propose($this->campaign(), 'tester');
        app(Publisher::class)->approve($decision, 'tester');

        $keywords = array_map(
            fn (array $op): string => $op['create']['keyword']['text'],
            $this->sent('adGroupCriteria:mutate')['operations'],
        );

        $this->assertContains('ad blocker', $keywords);
    }

    /**
     * A failure removes the budget as well as the campaign.
     *
     * A Google campaign budget is an account level object the campaign points
     * at, so deleting the campaign alone leaves one behind on every failure.
     */
    public function test_a_failure_part_way_through_removes_what_was_created(): void
    {
        $resource = fn (string $path): array => ['results' => [['resourceName' => $path]]];

        Http::fake([
            'oauth2.googleapis.com/*' => Http::response(['access_token' => 'token', 'expires_in' => 3600]),
            // The second response in each sequence is the rollback's remove.
            '*campaignBudgets:mutate' => Http::sequence()
                ->push($resource('customers/1234567890/campaignBudgets/111'))
                ->push(['results' => []]),
            '*campaigns:mutate' => Http::sequence()
                ->push($resource('customers/1234567890/campaigns/222'))
                ->push(['results' => []]),
            // The ad group is where it breaks, after campaign and budget exist.
            '*adGroups:mutate' => Http::response(['error' => ['message' => 'Ad group refused']], 400),
            '*campaignCriteria:mutate' => Http::response(['results' => []]),
            '*googleAds:searchStream' => Http::response([['results' => []]]),
        ]);

        $campaign = $this->campaign();
        $decision = app(Publisher::class)->propose($campaign, 'tester');

        try {
            app(Publisher::class)->approve($decision, 'tester');
        } catch (\Throwable) {
            // Publisher rethrows after rolling back, which is the Meta behaviour too.
        }

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

        $removals = [];

        foreach (Http::recorded() as [$request]) {
            $data = $request->data();

            if (isset($data['operations'][0]['remove'])) {
                $removals[] = $data['operations'][0]['remove'];
            }
        }

        $this->assertContains('customers/1234567890/campaigns/222', $removals, 'The campaign was left behind.');
        $this->assertContains('customers/1234567890/campaignBudgets/111', $removals, 'The budget was orphaned.');
    }

    /**
     * Picking Display builds a Display campaign, not a Search one.
     *
     * The payload hardcoded 'search'. Offering three types while always
     * building one would have been the worst kind of wrong: it succeeds, and
     * nothing anywhere says the choice was ignored.
     */
    public function test_choosing_display_builds_a_display_campaign(): void
    {
        $this->fakeGoogle();

        $campaign = $this->campaign(['campaign_type' => 'display']);

        $publisher = app(GooglePublisher::class);
        $created = [];

        try {
            $publisher->create($campaign, 'tester', $created);
        } catch (\Throwable) {
            // The fake does not model every Display response; what is asserted
            // is the payload that went out, not that the fake satisfied it.
        }

        $sent = $this->sent('campaigns:mutate')['operations'][0]['create'] ?? [];

        $this->assertSame('DISPLAY', $sent['advertisingChannelType'] ?? null);
        $this->assertSame('PAUSED', $sent['status'] ?? null);
    }

    /** And Search still builds Search, which is what makes the branch a choice. */
    public function test_choosing_search_still_builds_a_search_campaign(): void
    {
        $this->fakeGoogle();

        $campaign = $this->campaign(['campaign_type' => 'search']);

        $decision = app(Publisher::class)->propose($campaign, 'tester');
        app(Publisher::class)->approve($decision, 'tester');

        $sent = $this->sent('campaigns:mutate')['operations'][0]['create'];

        $this->assertSame('SEARCH', $sent['advertisingChannelType']);
    }

    /**
     * The conversion action does not go out as selective optimization.
     *
     * It used to, on Search, Display and Performance Max alike, and Google
     * refuses it on Search: publishing campaign 54 on 23 Sep failed the first
     * mutate with OPERATION_NOT_PERMITTED_FOR_CONTEXT and named no field.
     * Selective optimization belongs to App campaigns.
     *
     * Sending nothing is not a loss: a campaign with no conversion settings of
     * its own uses the account's conversion goals. Narrowing a campaign to one
     * action needs CampaignConversionGoal resources and is not built. The
     * action is still asked for and still stored, because the gate reads it and
     * it is the answer to what counts as success; it just does not reach Google.
     */
    public function test_the_conversion_action_is_not_sent_as_selective_optimization(): void
    {
        $this->fakeGoogle();

        $decision = app(Publisher::class)->propose($this->campaign(), 'tester');
        app(Publisher::class)->approve($decision, 'tester');

        $sent = $this->sent('campaigns:mutate')['operations'][0]['create'];

        $this->assertArrayNotHasKey(
            'selectiveOptimization',
            $sent,
            'Google refuses this on a Search campaign and makes nothing at all.',
        );
    }

    /**
     * The campaign is still created, which is the part that was failing.
     *
     * Publishing campaign 54 on the live account died on the first mutate with
     * OPERATION_NOT_PERMITTED_FOR_CONTEXT and no field named, so the campaign,
     * its budget and everything under it never existed.
     */
    public function test_a_search_campaign_is_created_with_its_bidding_intact(): void
    {
        $this->fakeGoogle();

        $decision = app(Publisher::class)->propose($this->campaign(), 'tester');
        app(Publisher::class)->approve($decision, 'tester');

        $sent = $this->sent('campaigns:mutate')['operations'][0]['create'];

        $this->assertSame('SEARCH', $sent['advertisingChannelType'] ?? null);
        $this->assertSame('PAUSED', $sent['status'] ?? null);
        $this->assertArrayHasKey('maximizeConversions', $sent);
    }

    /** The networks the buyer picked, not a hardcoded set. */
    public function test_the_chosen_networks_are_sent(): void
    {
        $this->fakeGoogle();

        $campaign = $this->campaign(['placements' => ['search', 'display']]);

        $decision = app(Publisher::class)->propose($campaign, 'tester');
        app(Publisher::class)->approve($decision, 'tester');

        $networks = $this->sent('campaigns:mutate')['operations'][0]['create']['networkSettings'];

        $this->assertTrue($networks['targetGoogleSearch']);
        $this->assertTrue($networks['targetContentNetwork'], 'Display was chosen and not sent.');
        $this->assertFalse($networks['targetSearchNetwork'], 'Search partners was not chosen.');
    }

    /** An end date has to reach Google, or the campaign runs until somebody notices. */
    public function test_the_schedule_is_sent(): void
    {
        $this->fakeGoogle();

        $campaign = $this->campaign(['ends_at' => now()->addDays(30)]);

        $decision = app(Publisher::class)->propose($campaign, 'tester');
        app(Publisher::class)->approve($decision, 'tester');

        $sent = $this->sent('campaigns:mutate')['operations'][0]['create'];

        $this->assertSame(now()->addDays(30)->format('Ymd'), $sent['endDate'] ?? null);
    }

    /** A platform with no publisher is refused by name, not by exception. */
    public function test_a_platform_with_no_publisher_is_refused_clearly(): void
    {
        $campaign = Campaign::create([
            'user_id' => $this->user->id,
            'name' => 'Taboola one',
            'platform' => 'taboola',
            'status' => 'draft',
        ]);

        $decision = app(Publisher::class)->propose($campaign, 'tester');
        $result = app(Publisher::class)->approve($decision, 'tester');

        $this->assertSame('blocked', $result->state);
        $this->assertSame('draft', $campaign->fresh()->status, 'The campaign was left mid publish.');
    }

    /**
     * Six descriptions do not reach Google, which takes four.
     *
     * Publishing campaign 47 on 23 Sep failed with "Request contains an invalid
     * argument. (TOO_MANY)". Three ads, each contributing its description and
     * its primary text, made six descriptions; a responsive search ad holds
     * four. The error names no field, so nothing in it pointed at the copy, and
     * the campaign sat at "Ready to publish" with no way forward.
     *
     * The factory above gives every ad the same text for both fields, so
     * unique() collapsed them to three and no test ever saw six. Real ad copy
     * differs, which is the whole point of writing it.
     */
    public function test_descriptions_are_capped_at_what_a_responsive_search_ad_holds(): void
    {
        $this->fakeGoogle();

        $campaign = $this->campaign();

        foreach ($campaign->ads as $index => $ad) {
            $ad->update(['primary_text' => 'A different primary text number '.$index]);
        }

        $publisher = app(GooglePublisher::class);
        $created = [];

        try {
            $publisher->create($campaign->fresh(), 'tester', $created);
        } catch (\Throwable) {
            // What is asserted is the payload that went out.
        }

        $ad = $this->sent('adGroupAds:mutate')['operations'][0]['create']['ad']['responsiveSearchAd'] ?? [];

        $this->assertLessThanOrEqual(
            GoogleRules::MAX_DESCRIPTIONS,
            count($ad['descriptions'] ?? []),
            'Google refuses the whole request when there are more than four.',
        );

        $this->assertGreaterThanOrEqual(
            2,
            count($ad['descriptions'] ?? []),
            'Capping must not take it below Google\'s minimum of two.',
        );

        $this->assertLessThanOrEqual(
            GoogleRules::MAX_HEADLINES,
            count($ad['headlines'] ?? []),
        );
    }

    /**
     * Every level Google creates is paused, not just the campaign and the ad.
     *
     * The ad group was created ENABLED. Nothing served through it, because a
     * paused campaign stops everything and the ad was paused as well, so there
     * were two independent stops. But it made createPausedCampaign() untrue of
     * a third of what it creates, and it differed from Meta, where campaign,
     * ad set and ad are all paused. Seen on the live account on 23 Sep:
     * campaign 24285639010 PAUSED, ad 825696152281 PAUSED, ad group
     * 203150420849 ENABLED.
     *
     * Keywords stay enabled on purpose: they are criteria rather than a serving
     * level, and the paused ad group above them is what stops them.
     */
    public function test_every_level_google_creates_is_paused(): void
    {
        $this->fakeGoogle();

        $publisher = app(GooglePublisher::class);
        $created = [];

        try {
            $publisher->create($this->campaign(), 'tester', $created);
        } catch (\Throwable) {
            // The payloads that went out are what is asserted.
        }

        foreach (['campaigns:mutate', 'adGroups:mutate', 'adGroupAds:mutate'] as $endpoint) {
            $sent = $this->sent($endpoint)['operations'][0]['create'] ?? null;

            $this->assertNotNull($sent, "Nothing was sent to {$endpoint}.");
            $this->assertSame('PAUSED', $sent['status'] ?? null, "{$endpoint} was created enabled.");
        }
    }

    /** Ad group omits cpcBidMicros on Smart Bidding (Target CPA) to prevent OPERATION_NOT_PERMITTED_FOR_CONTEXT. */
    public function test_ad_group_omits_cpc_bid_on_smart_bidding_campaigns(): void
    {
        $this->fakeGoogle();

        $campaign = $this->campaign([
            'bid_strategy' => 'TARGET_CPA',
            'bid_amount' => '1.50',
        ]);

        $publisher = app(GooglePublisher::class);
        $created = [];

        try {
            $publisher->create($campaign, 'tester', $created);
        } catch (\Throwable) {
        }

        $adGroup = $this->sent('adGroups:mutate')['operations'][0]['create'] ?? [];
        $this->assertArrayNotHasKey('cpcBidMicros', $adGroup, 'Smart bidding ad group must not send cpcBidMicros');
    }

    public function test_unmapped_countries_are_refused_before_any_google_request(): void
    {
        $this->fakeGoogle();
        $campaign = $this->campaign(['locations' => ['countries' => ['US', 'ZA']]]);
        $created = [];

        try {
            app(GooglePublisher::class)->create($campaign, 'test', $created);
            $this->fail('An unmapped country must not be silently omitted.');
        } catch (RuntimeException $exception) {
            $this->assertStringContainsString('country mapping is not available for: ZA', $exception->getMessage());
        }

        Http::assertNotSent(fn (Request $request): bool => str_contains($request->url(), ':mutate'));
        $this->assertSame([], $created);
    }
}
