<?php

namespace Tests\Feature;

use App\Campaigns\Platforms\GoogleRules;
use App\Campaigns\Publishing\GooglePublisher;
use App\Models\Brief;
use App\Models\Campaign;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request as ClientRequest;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

/**
 * Fields that reach the platform because the publisher put them there.
 *
 * The suite had tests for each of these and none of them held. DevicesReachGoogleTest,
 * ConversionGoalReachesGoogleTest and SpecialAdCategoryTest all build the payload
 * array by hand and pass it to the service, so they cover the consumer while the
 * defect each was written for lives in the producer: GooglePublisher's lines that
 * read the field off the campaign can be reverted to a constant with all of them
 * green. Proven by mutation - 'devices' => [], 'conversion_action' => null and
 * specialAdCategories: [] each left the whole suite passing.
 *
 * These drive the publisher itself and assert on the bytes that went out, so the
 * producing line is what is pinned.
 */
class WhatThePublisherActuallySendsTest extends TestCase
{
    use RefreshDatabase;

    private User $user;

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

        $this->user = User::factory()->create();
        $this->actingAs($this->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',
        ]);

        $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 = [], array $adAttributes = []): Campaign
    {
        $brief = Brief::create([
            'user_id' => $this->user->id,
            'conversation_id' => 'conv-what-is-sent',
            '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'],
            ],
        ]);

        $campaign = Campaign::create([
            'user_id' => $this->user->id,
            'brief_id' => $brief->id,
            'name' => 'iBlockAds Search',
            'platform' => 'google',
            'campaign_type' => 'search',
            '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']],
            ...$attributes,
        ]);

        $campaign->ads()->create([
            'headline' => 'Block ads now',
            'primary_text' => 'Browse faster with iBlockAds today.',
            'description' => 'Free ad blocker for every browser.',
            'cta' => 'LEARN_MORE',
            ...$adAttributes,
        ]);

        return $campaign;
    }

    private function publish(Campaign $campaign): void
    {
        $created = [];

        try {
            app(GooglePublisher::class)->create($campaign, 'tester', $created);
        } catch (\Throwable) {
            // What went out is what is asserted.
        }
    }

    /** Every request body sent to one Google endpoint. */
    private function sentTo(string $needle): array
    {
        $bodies = [];

        foreach (Http::recorded() as [$request]) {
            /** @var ClientRequest $request */
            if (str_contains($request->url(), $needle)) {
                $bodies[] = $request->data();
            }
        }

        return $bodies;
    }

    /**
     * The defect: a campaign told "mobile only" reached desktops.
     *
     * The publisher is what carries the buyer's answer into the payload, and
     * nothing pinned that line.
     */
    public function test_the_publisher_carries_the_chosen_devices(): void
    {
        $this->publish($this->campaign(['audience' => ['devices' => ['mobile']]]));

        $sent = json_encode($this->sentTo('campaignCriteria:mutate'));

        $this->assertStringContainsString('device', (string) $sent, 'no device criteria were sent at all');
        $this->assertStringContainsString('DESKTOP', (string) $sent, 'mobile-only should exclude desktop explicitly');
    }

    /**
     * The defect: the buyer was asked what counts as success and Google was
     * never told.
     */
    public function test_the_publisher_carries_the_conversion_action(): void
    {
        // The action is not sent on the campaign itself: it is looked up to
        // find its category, and the campaign's conversion goals are then
        // narrowed to that category. The resource name never appears in a
        // request - the match happens in PHP - so what is observable is whether
        // the narrowing call happens at all, which it does not when the
        // publisher stops passing the action.
        Http::fake(function ($request) {
            $url = $request->url();
            $resource = fn (string $p): array => ['results' => [['resourceName' => $p]]];

            if (str_contains($url, 'oauth2.googleapis.com')) {
                return Http::response(['access_token' => 'token', 'expires_in' => 3600]);
            }

            if (str_contains($url, 'googleAds:searchStream')) {
                $query = (string) ($request->data()['query'] ?? '');

                if (str_contains($query, 'FROM conversion_action')) {
                    return Http::response([['results' => [[
                        'conversionAction' => [
                            'resourceName' => 'customers/1234567890/conversionActions/9',
                            'name' => 'Demo Request',
                            'category' => 'SUBMIT_LEAD_FORM',
                            'type' => 'WEBPAGE',
                        ],
                    ]]]]);
                }

                if (str_contains($query, 'FROM campaign_conversion_goal')) {
                    return Http::response([['results' => [[
                        'campaignConversionGoal' => ['category' => 'PURCHASE', 'origin' => 'WEBSITE'],
                    ]]]]);
                }

                return Http::response([['results' => []]]);
            }

            foreach ([
                'campaignBudgets:mutate' => 'customers/1234567890/campaignBudgets/111',
                'campaigns:mutate' => 'customers/1234567890/campaigns/222',
                'adGroups:mutate' => 'customers/1234567890/adGroups/333',
                'adGroupAds:mutate' => 'customers/1234567890/adGroupAds/444',
            ] as $needle => $path) {
                if (str_contains($url, $needle)) {
                    return Http::response($resource($path));
                }
            }

            return Http::response(['results' => []]);
        });

        $this->publish($this->campaign());

        // narrowConversionGoals() returns at its first line when the action is
        // empty, so this query happening at all is what proves the publisher
        // carried the buyer's choice through.
        $lookups = array_filter(
            $this->sentTo('googleAds:searchStream'),
            fn (array $body): bool => str_contains((string) ($body['query'] ?? ''), 'FROM conversion_action'),
        );

        $this->assertNotEmpty(
            $lookups,
            'the conversion action was never looked up, so nothing was narrowed to it',
        );
    }

    /**
     * The defect: descriptions were capped and headlines were not.
     *
     * Google refuses the whole request with TOO_MANY rather than trimming, so
     * an over-long list loses the campaign. The existing test's fixture yields
     * 9 headlines against a cap of 15, so it holds either way.
     */
    public function test_the_publisher_caps_headlines_at_what_the_ad_holds(): void
    {
        $campaign = $this->campaign();

        foreach (range(1, 25) as $i) {
            $campaign->ads()->create([
                'headline' => "Block ads number {$i}",
                'primary_text' => "Body copy number {$i} for the responsive ad.",
                'description' => "Description number {$i}.",
                'cta' => 'LEARN_MORE',
            ]);
        }

        $this->publish($campaign);

        $body = (string) json_encode($this->sentTo('adGroupAds:mutate'));

        preg_match_all('/"text":/', $body, $matches);

        $this->assertNotEmpty($matches[0], 'no ad text was sent');
        $this->assertLessThanOrEqual(
            15 + 4,
            count($matches[0]),
            'more assets were sent than a responsive search ad accepts, so Google refuses the whole request',
        );
    }

    /**
     * Display is not Search, and was blocked forever by a Search-only rule.
     *
     * additionalProblems() demanded landing-page keywords for every campaign
     * type and keywords() threw for every type, while GoogleAdsService routes
     * display and performance max to their own create methods, neither of which
     * reads a keyword. No tool can supply keywords by hand, so a Display
     * campaign whose page yielded none had no way forward at all.
     */
    public function test_a_display_campaign_is_not_blocked_by_a_search_keyword_rule(): void
    {
        $brief = Brief::create([
            'user_id' => $this->user->id,
            'conversation_id' => 'conv-display',
            'landing_url' => 'https://iblockads.org/',
            'page_analysis' => [
                'url' => 'https://iblockads.org/',
                'title' => 'iBlockAds',
                'description' => 'Block annoying ads.',
                'headings' => ['iBlockAds'],
                'text' => 'Block annoying ads.',
                'keywords' => [],
            ],
        ]);

        $campaign = Campaign::create([
            'user_id' => $this->user->id,
            'brief_id' => $brief->id,
            'name' => 'iBlockAds Display',
            'platform' => 'google',
            'campaign_type' => 'display',
            'status' => 'draft',
            'objective' => 'LEADS',
            'ad_account_id' => '1234567890',
            'landing_url' => 'https://iblockads.org/',
            'budget' => 25,
            'budget_mode' => 'daily',
            'currency' => 'USD',
            'locations' => ['countries' => ['US']],
        ]);

        $problems = app(GoogleRules::class)->additionalProblems($campaign);

        $this->assertEmpty(
            array_filter($problems, fn (string $p): bool => str_contains($p, 'keywords')),
            'a Display campaign was blocked by a Search keyword requirement',
        );
    }
}
