<?php

namespace Tests\Feature;

use App\Campaigns\CampaignUpdater;
use App\Campaigns\PendingChanges;
use App\Campaigns\Platforms\Platforms;
use App\Models\Brief;
use App\Models\Campaign;
use App\Models\CampaignAd;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

/**
 * Editing a live Google campaign reaches Google.
 *
 * CampaignUpdater::apply() called the Meta Graph API for every platform. There
 * was no branch: a Google campaign's edits would have gone to Meta carrying a
 * Google customer id as the ad account and Google object ids as the entities,
 * and then been recorded as applied, because the snapshot moves whenever the
 * call does not throw.
 *
 * Nobody had hit it because Google publishing only started working on 23 Sep,
 * so until then no Google campaign was ever live enough to edit.
 */
class GoogleCampaignEditTest extends TestCase
{
    use RefreshDatabase;

    private User $user;

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

        config([
            'services.google_ads.refresh_token' => 'a-refresh-token',
            'services.google_ads.customer_id' => '3460855874',
            'services.google_ads.login_customer_id' => '8039000543',
            'services.google_ads.developer_token' => 'dev-token',
            'platforms.guardrails.change_cooldown_seconds' => 0,
            // The guardrail list is per ad account and holds Meta act_ ids by
            // default. Dev already carries the Google customer id, which is why
            // publishing worked there; the test environment does not.
            'platforms.guardrails.allowed_ad_accounts' => ['3460855874'],
        ]);

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

    private function fakeGoogle(): void
    {
        Http::fake([
            'oauth2.googleapis.com/*' => Http::response(['access_token' => 'token', 'expires_in' => 3600]),
            'googleads.googleapis.com/*' => Http::response(['results' => [['resourceName' => 'customers/3460855874/campaigns/24285639010']]]),
            'graph.facebook.com/*' => Http::response(['error' => ['message' => 'Meta must not be called for a Google campaign']], 500),
        ]);
    }

    private function liveGoogleCampaign(): Campaign
    {
        $brief = Brief::create([
            'user_id' => $this->user->id,
            'conversation_id' => 'conv-google-edit',
            'landing_url' => 'https://iblockads.org/',
        ]);

        $campaign = Campaign::create([
            'user_id' => $this->user->id,
            'brief_id' => $brief->id,
            'name' => 'iBlockAds Google Traffic',
            'platform' => 'google',
            'status' => 'published',
            'campaign_type' => 'search',
            'objective' => 'WEBSITE_TRAFFIC',
            'ad_account_id' => '3460855874',
            'landing_url' => 'https://iblockads.org/',
            'budget' => 10,
            'budget_level' => 'campaign',
            'budget_mode' => 'daily',
            'currency' => 'USD',
            'bid_strategy' => 'MAXIMIZE_CLICKS',
            'locations' => ['countries' => ['US']],
            'placements' => ['search'],
            'utm' => '',
            'external_campaign_id' => '24285639010',
            'external_ids' => [
                'campaign_id' => '24285639010',
                'budget_id' => '15896507457',
                'ad_group_id' => '203150420849',
                'ad_id' => '825696152281',
            ],
        ]);

        CampaignAd::create([
            'campaign_id' => $campaign->id,
            'headline' => 'Block Annoying Ads',
            'description' => 'Block pop-ups and banners instantly.',
            'primary_text' => 'Install iBlockAds.',
            'cta' => 'LEARN_MORE',
            'status' => 'published',
        ]);

        $campaign = $campaign->fresh();
        $campaign->update(['published_state' => app(PendingChanges::class)->snapshot($campaign)]);

        return $campaign->fresh();
    }

    /** @return list<array{url: string, body: array<string,mixed>}> */
    private function googleCalls(): array
    {
        $calls = [];

        foreach (Http::recorded() as [$request]) {
            /** @var Request $request */
            if (str_contains($request->url(), 'googleads.googleapis.com')) {
                $calls[] = ['url' => $request->url(), 'body' => $request->data()];
            }
        }

        return $calls;
    }

    public function test_a_budget_change_goes_to_google_and_not_to_meta(): void
    {
        $this->fakeGoogle();

        $campaign = $this->liveGoogleCampaign();
        $campaign->update(['budget' => 25]);

        $updater = app(CampaignUpdater::class);
        $change = $updater->apply($updater->propose($campaign->fresh(), 'tester'), 'tester');

        $this->assertSame('applied', $change->fresh()->state, 'Change failed: '.$change->fresh()->error);

        foreach (Http::recorded() as [$request]) {
            $this->assertStringNotContainsString(
                'graph.facebook.com',
                $request->url(),
                'A Google campaign was edited against Meta.',
            );
        }

        $budget = collect($this->googleCalls())->firstWhere(fn (array $c): bool => str_contains($c['url'], 'campaignBudgets'));

        $this->assertNotNull($budget, 'The budget never reached Google.');
        $this->assertSame('25000000', $budget['body']['operations'][0]['update']['amountMicros'] ?? null);
        $this->assertSame('amountMicros', $budget['body']['operations'][0]['updateMask'] ?? null);
    }

    /** The name is a campaign field, and the mask must name only it. */
    public function test_a_name_change_sends_a_mask_covering_only_the_name(): void
    {
        $this->fakeGoogle();

        $campaign = $this->liveGoogleCampaign();
        $campaign->update(['name' => 'iBlockAds Google Traffic v2']);

        $updater = app(CampaignUpdater::class);
        $updater->apply($updater->propose($campaign->fresh(), 'tester'), 'tester');

        $call = collect($this->googleCalls())->firstWhere(fn (array $c): bool => str_contains($c['url'], 'campaigns:mutate'));

        $this->assertNotNull($call, 'The name never reached Google.');
        $this->assertSame('iBlockAds Google Traffic v2', $call['body']['operations'][0]['update']['name'] ?? null);
        $this->assertSame('name', $call['body']['operations'][0]['updateMask'] ?? null);
    }

    /**
     * Bidding goes whole, because half a strategy is not a valid campaign.
     *
     * Google reads the strategy and its target as one block, so naming only
     * part of it in the mask clears the rest.
     */
    public function test_a_bidding_change_sends_the_whole_strategy_block(): void
    {
        $this->fakeGoogle();

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

        $updater = app(CampaignUpdater::class);
        $updater->apply($updater->propose($campaign->fresh(), 'tester'), 'tester');

        $update = collect($this->googleCalls())
            ->firstWhere(fn (array $c): bool => str_contains($c['url'], 'campaigns:mutate'))['body']['operations'][0] ?? [];

        $this->assertSame('3500000', $update['update']['targetCpa']['targetCpaMicros'] ?? null);
        $this->assertStringContainsString('targetCpa', $update['updateMask'] ?? '');
    }

    /**
     * Every field the rules call editable is one the publisher can send.
     *
     * This is the check that matters. A field declared editable and silently
     * not sent is worse than one refused: the edit is accepted, the snapshot
     * moves, and the campaign and the platform disagree with nothing saying so.
     * "locations" was exactly that until it was taken off the list.
     */
    public function test_every_editable_field_reaches_google(): void
    {
        $values = [
            'name' => 'Renamed',
            'budget' => 33,
            'bid_strategy' => 'TARGET_CPA',
            'bid_amount' => 2.5,
            'starts_at' => now()->addDay(),
            'ends_at' => now()->addDays(10),
            'placements' => ['search', 'display'],
        ];

        foreach (app(Platforms::class)->for('google')->editableAfterPublish() as $field) {
            $this->assertArrayHasKey(
                $field,
                $values,
                "[{$field}] is declared editable and this test does not know how to change it, "
                    .'which means nobody has checked it reaches Google.',
            );

            $this->fakeGoogle();

            $campaign = $this->liveGoogleCampaign();
            $campaign->update([$field => $values[$field]]);

            $updater = app(CampaignUpdater::class);
            $change = $updater->apply($updater->propose($campaign->fresh(), 'tester'), 'tester');

            $this->assertSame('applied', $change->fresh()->state, "[{$field}] was refused: ".$change->fresh()->error);
            $this->assertNotEmpty($this->googleCalls(), "[{$field}] changed and nothing was sent to Google.");

            Campaign::query()->delete();
        }
    }

    /** A country change is refused rather than silently lost. */
    public function test_changing_countries_is_not_claimed_as_editable(): void
    {
        $this->assertNotContains(
            'locations',
            app(Platforms::class)->for('google')->editableAfterPublish(),
            'Geo targets are campaign criteria and nothing pushes them, so claiming them loses the edit.',
        );
    }
}
