<?php

namespace App\Services;

use App\Models\GoogleAdsCampaign;
use App\Models\GoogleAdsConnection;
use App\Services\Platforms\Support\CallContext;
use App\Services\Platforms\Support\CallLogger;
use Illuminate\Support\Facades\Http;
use RuntimeException;

class GoogleAdsService
{
    // No FALLBACK_REFRESH_TOKEN.
    //
    // A live Google refresh token was committed here as a public constant and
    // used whenever GOOGLE_ADS_REFRESH_TOKEN was unset, which meant any
    // environment missing its configuration silently reached Google as whoever
    // owned that token, and anyone with repository access held a long-lived
    // credential. The token in that constant has to be revoked at Google as
    // well as removed here: deleting it from the source does not un-share it.
    //
    // With it gone, an unconfigured environment fails with the reconnect
    // message below, which is the correct outcome.

    public function __construct(private readonly CallLogger $log) {}

    /**
     * Every call to the Ads API, through the same guardrails Meta's go through.
     *
     * Google was reaching the network directly, which meant three things were
     * true of a Google campaign and not of a Meta one: the account allow list
     * did not apply, the hourly call budget did not count it, and nothing
     * appeared in api_calls, so there was no record that a call had been made
     * at all.
     *
     * The OAuth token exchange deliberately does not come through here. It is
     * not an Ads API call, it does not touch an ad account, and its payload is
     * a refresh token that has no business in an audit row.
     *
     * @param  \Closure(): array<string,mixed>  $call
     * @return array<string,mixed>
     */
    private function audited(string $method, string $endpoint, ?string $customerId, array $request, bool $mutating, \Closure $call, bool $fresh = false): array
    {
        return $this->log->record(
            new CallContext(
                transport: 'google_ads',
                method: $method,
                endpoint: $endpoint,
                adAccountId: $customerId,
                request: $request,
                mutating: $mutating,
                platform: 'google',
                memoize: ! $fresh,
            ),
            $call,
        );
    }

    public function fetchChildAccounts(GoogleAdsConnection $connection): array
    {
        $managerCustomerId = $connection->manager_customer_id ?: config('services.google_ads.login_customer_id');
        if (blank($managerCustomerId)) {
            throw new RuntimeException('Select a manager customer before loading child accounts.');
        }

        $managerCustomerId = str_replace('-', '', $managerCustomerId);
        $token = $this->accessToken($connection);
        $query = 'SELECT customer_client.client_customer, customer_client.manager, customer_client.status FROM customer_client';

        // Listing the accounts under a manager is a read of the manager itself,
        // so that is the account the call is attributed to. Guarded like any
        // other: an allow list that only covered writes would let the agent
        // enumerate accounts it may not touch.
        $payload = $this->audited(
            'POST',
            'customers/'.$managerCustomerId.'/googleAds:searchStream',
            $managerCustomerId,
            ['query' => $query],
            mutating: false,
            call: function () use ($token, $managerCustomerId, $query): array {
                $response = Http::withToken($token)
                    ->withHeaders([
                        'developer-token' => GoogleAdsConnection::developerToken(),
                        'login-customer-id' => $managerCustomerId,
                        'Content-Type' => 'application/json',
                    ])
                    ->connectTimeout(5)
                    ->timeout(20)
                    ->post('https://googleads.googleapis.com/'.config('services.google_ads.api_version', 'v25').'/customers/'.$managerCustomerId.'/googleAds:searchStream', [
                        'query' => $query,
                    ]);

                if ($response->failed()) {
                    throw new RuntimeException((string) data_get($response->json(), 'error.message', 'Google Ads child accounts could not be loaded.'));
                }

                return $response->json();
            },
        );

        return collect($payload)
            ->flatMap(fn (array $chunk): array => $chunk['results'] ?? [])
            ->map(function (array $result): ?string {
                $client = $result['customerClient'] ?? [];
                if (($client['manager'] ?? true) || ($client['status'] ?? '') !== 'ENABLED') {
                    return null;
                }

                return str_replace('customers/', '', (string) ($client['clientCustomer'] ?? ''));
            })
            ->filter()
            ->unique()
            ->values()
            ->all();
    }

    /** @return list<array{customer_id: string, name: string}> */
    public function childAccountOptions(GoogleAdsConnection $connection): array
    {
        $managerCustomerId = str_replace('-', '', (string) $connection->manager_customer_id);

        return collect($connection->available_customer_ids ?? [])
            ->reject(fn (string $customerId): bool => str_replace('-', '', $customerId) === $managerCustomerId)
            ->map(function (string $customerId) use ($connection): array {
                $customerId = str_replace('-', '', $customerId);
                $response = $this->search($connection, 'SELECT customer.id, customer.descriptive_name FROM customer WHERE customer.id = '.$customerId, $customerId);
                $customer = data_get($response, '0.results.0.customer', []);

                return [
                    'customer_id' => $customerId,
                    'name' => (string) ($customer['descriptiveName'] ?? 'Google Ads account '.$customerId),
                ];
            })
            ->values()
            ->all();
    }

    /**
     * @param  array<string,mixed>  $created  filled in as each object is made,
     *                                        so a failure part way through still
     *                                        knows what exists and can remove it.
     *                                        Without this the ids only appeared
     *                                        on success and a campaign that broke
     *                                        at the ad group left an orphaned
     *                                        campaign and budget on the account.
     */
    public function createPausedCampaign(GoogleAdsConnection $connection, array $data, array &$created = []): array
    {
        $this->customerId($connection);

        if (strtolower((string) ($data['campaign_type'] ?? 'search')) === 'video') {
            $data['campaign_type'] = 'performance_max';
        }

        if (strtolower((string) ($data['campaign_type'] ?? 'search')) === 'display') {
            return $this->createPausedDisplayCampaign($connection, $data);
        }

        if (strtolower((string) ($data['campaign_type'] ?? 'search')) === 'performance_max') {
            return $this->createPausedPerformanceMaxCampaign($connection, $data);
        }

        if (strtolower((string) ($data['campaign_type'] ?? 'search')) !== 'search') {
            throw new RuntimeException('This Google Ads campaign type is not implemented yet.');
        }

        $budgetMicros = (string) round(((float) $data['daily_budget']) * 1000000);
        $budgetResponse = $this->mutate($connection, 'campaignBudgets', [
            'create' => [
                'name' => $data['name'].' Budget',
                'amountMicros' => $budgetMicros,
                'deliveryMethod' => 'STANDARD',
                'explicitlyShared' => false,
            ],
        ]);
        $budgetResource = $this->firstResourceName($budgetResponse, 'campaignBudgetResult');
        if (! is_string($budgetResource)) {
            throw new RuntimeException('Google Ads did not return a campaign budget.');
        }
        $created['budget_id'] = $this->resourceId($budgetResource);

        $response = $this->mutate($connection, 'campaigns', [
            'create' => [
                'name' => $data['name'],
                'status' => 'PAUSED',
                'advertisingChannelType' => 'SEARCH',
                'campaignBudget' => $budgetResource,
                ...$this->biddingSettings(
                    $data['bidding_strategy'] ?? 'max_conversions',
                    isset($data['bid_target']) ? (float) $data['bid_target'] : null,
                ),
                'geoTargetTypeSetting' => [
                    'positiveGeoTargetType' => 'PRESENCE',
                ],
                'containsEuPoliticalAdvertising' => 'DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING',
                ...$this->scheduleSettings($data),
                ...$this->conversionSettings($data),
                'networkSettings' => $this->networkSettings($data, [
                    'targetGoogleSearch' => true,
                    'targetSearchNetwork' => true,
                    'targetContentNetwork' => false,
                    'targetPartnerSearchNetwork' => false,
                ]),
            ],
        ]);

        $campaignResource = $this->firstResourceName($response, 'campaignResult');
        if (! is_string($campaignResource)) {
            throw new RuntimeException('Google Ads did not return a campaign.');
        }
        $created['campaign_id'] = $this->resourceId($campaignResource);

        $this->addTargetingCriteria($connection, $campaignResource, $data);

        // After the create, because the goals do not exist until the
        // campaign does. Google makes one per conversion category on the
        // account and marks them all biddable, so without this a campaign
        // told to optimise for one conversion optimises for every one.
        $this->narrowConversionGoals($connection, $this->resourceId($campaignResource), $data);

        $adGroup = [
            'name' => $data['ad_group_name'],
            'campaign' => $campaignResource,
            // Paused like everything else this method creates. It was the
            // one level left enabled, which nothing served through because
            // the campaign and the ad are both paused, but it made
            // "createPausedCampaign" untrue of a third of what it creates
            // and differed from Meta, where all three levels are paused.
            'status' => 'PAUSED',
            'type' => 'SEARCH_STANDARD',
        ];

        // Setting cpcBidMicros on an ad group when the campaign uses Smart Bidding
        // (Target CPA, Maximize Conversions, etc.) is rejected by Google Ads with
        // OPERATION_NOT_PERMITTED_FOR_CONTEXT. Only manual CPC accepts this field.
        if (strtolower((string) ($data['bidding_strategy'] ?? '')) === 'manual_cpc') {
            $adGroup['cpcBidMicros'] = '1000000';
        }

        $adGroupResponse = $this->mutate($connection, 'adGroups', [
            'create' => $adGroup,
        ]);
        $adGroupResource = $this->firstResourceName($adGroupResponse, 'adGroupResult');
        if (! is_string($adGroupResource)) {
            throw new RuntimeException('Google Ads did not return an ad group.');
        }
        $created['ad_group_id'] = $this->resourceId($adGroupResource);

        $keywordOperations = collect(array_filter(array_map('trim', explode(',', $data['keywords']))))
            ->map(fn (string $keyword): array => [
                'create' => [
                    'adGroup' => $adGroupResource,
                    'status' => 'ENABLED',
                    'keyword' => [
                        'text' => $keyword,
                        'matchType' => 'PHRASE',
                    ],
                ],
            ])
            ->values()
            ->all();
        if ($keywordOperations === []) {
            throw new RuntimeException('At least one keyword is required.');
        }
        $this->mutateMany($connection, 'adGroupCriteria', $keywordOperations);

        $adResponse = $this->mutate($connection, 'adGroupAds', [
            'create' => [
                'adGroup' => $adGroupResource,
                'status' => 'PAUSED',
                'ad' => [
                    'finalUrls' => [$data['final_url']],
                    'responsiveSearchAd' => [
                        'headlines' => collect($data['headlines'])->map(fn (string $headline): array => ['text' => $headline])->all(),
                        'descriptions' => collect($data['descriptions'])->map(fn (string $description): array => ['text' => $description])->all(),
                    ],
                ],
            ],
        ]);
        $adResource = $this->firstResourceName($adResponse, 'adGroupAdResult');
        if (! is_string($adResource)) {
            throw new RuntimeException('Google Ads did not return an ad.');
        }

        return [
            'budget_id' => $this->resourceId($budgetResource),
            'campaign_id' => $this->resourceId($campaignResource),
            'ad_group_id' => $this->resourceId($adGroupResource),
            'ad_id' => $this->resourceId($adResource),
        ];
    }

    private function createPausedDisplayCampaign(GoogleAdsConnection $connection, array $data): array
    {
        $budgetMicros = (string) round(((float) $data['daily_budget']) * 1000000);
        $budgetResponse = $this->mutate($connection, 'campaignBudgets', [
            'create' => [
                'name' => $data['name'].' Budget',
                'amountMicros' => $budgetMicros,
                'deliveryMethod' => 'STANDARD',
                'explicitlyShared' => false,
            ],
        ]);
        $budgetResource = $this->firstResourceName($budgetResponse, 'campaignBudgetResult');
        if (! is_string($budgetResource)) {
            throw new RuntimeException('Google Ads did not return a Display campaign budget.');
        }

        $campaignResponse = $this->mutate($connection, 'campaigns', [
            'create' => [
                'name' => $data['name'],
                'status' => 'PAUSED',
                'advertisingChannelType' => 'DISPLAY',
                'campaignBudget' => $budgetResource,
                ...$this->biddingSettings(
                    $data['bidding_strategy'] ?? 'max_conversions',
                    isset($data['bid_target']) ? (float) $data['bid_target'] : null,
                ),
                'geoTargetTypeSetting' => ['positiveGeoTargetType' => 'PRESENCE'],
                'containsEuPoliticalAdvertising' => 'DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING',
                ...$this->scheduleSettings($data),
                ...$this->conversionSettings($data),
                'networkSettings' => $this->networkSettings($data, [
                    'targetGoogleSearch' => false,
                    'targetSearchNetwork' => false,
                    'targetContentNetwork' => true,
                    'targetPartnerSearchNetwork' => false,
                ]),
            ],
        ]);
        $campaignResource = $this->firstResourceName($campaignResponse, 'campaignResult');
        if (! is_string($campaignResource)) {
            throw new RuntimeException('Google Ads did not return a Display campaign.');
        }
        $this->addTargetingCriteria($connection, $campaignResource, $data);

        // After the create, because the goals do not exist until the
        // campaign does. Google makes one per conversion category on the
        // account and marks them all biddable, so without this a campaign
        // told to optimise for one conversion optimises for every one.
        $this->narrowConversionGoals($connection, $this->resourceId($campaignResource), $data);

        $adGroupResponse = $this->mutate($connection, 'adGroups', [
            'create' => [
                'name' => $data['ad_group_name'],
                'campaign' => $campaignResource,
                // Paused, as above.
                'status' => 'PAUSED',
                'type' => 'DISPLAY_STANDARD',
                'cpmBidMicros' => (string) round(((float) ($data['cpm_bid'] ?? 5)) * 1000000),
            ],
        ]);
        $adGroupResource = $this->firstResourceName($adGroupResponse, 'adGroupResult');
        if (! is_string($adGroupResource)) {
            throw new RuntimeException('Google Ads did not return a Display ad group.');
        }

        $imageAsset = static fn (string $resource): array => ['asset' => trim($resource)];
        $adResponse = $this->mutate($connection, 'adGroupAds', [
            'create' => [
                'adGroup' => $adGroupResource,
                'status' => 'PAUSED',
                'ad' => [
                    'finalUrls' => [$data['final_url']],
                    'responsiveDisplayAd' => [
                        'marketingImages' => array_map($imageAsset, $data['marketing_image_assets']),
                        'squareMarketingImages' => array_map($imageAsset, $data['square_marketing_image_assets']),
                        'logoImages' => array_map($imageAsset, $data['logo_image_assets'] ?? []),
                        'headlines' => array_map(fn (string $text): array => ['text' => $text], $data['headlines']),
                        'longHeadline' => ['text' => $data['long_headline']],
                        'descriptions' => array_map(fn (string $text): array => ['text' => $text], $data['descriptions']),
                        'businessName' => $data['business_name'],
                        'callToActionText' => $data['call_to_action_text'] ?? null,
                        'allowFlexibleColor' => true,
                    ],
                ],
            ],
        ]);
        $adResource = $this->firstResourceName($adResponse, 'adGroupAdResult');
        if (! is_string($adResource)) {
            throw new RuntimeException('Google Ads did not return a Display ad.');
        }

        return [
            'budget_id' => $this->resourceId($budgetResource),
            'campaign_id' => $this->resourceId($campaignResource),
            'ad_group_id' => $this->resourceId($adGroupResource),
            'ad_id' => $this->resourceId($adResource),
        ];
    }

    private function createPausedPerformanceMaxCampaign(GoogleAdsConnection $connection, array $data): array
    {
        $budgetMicros = (string) round(((float) $data['daily_budget']) * 1000000);
        $budgetResponse = $this->mutate($connection, 'campaignBudgets', [
            'create' => [
                'name' => $data['name'].' Budget',
                'amountMicros' => $budgetMicros,
                'deliveryMethod' => 'STANDARD',
                'explicitlyShared' => false,
            ],
        ]);
        $budgetResource = $this->firstResourceName($budgetResponse, 'campaignBudgetResult');
        if (! is_string($budgetResource)) {
            throw new RuntimeException('Google Ads did not return a Performance Max budget.');
        }

        $campaignResponse = $this->mutate($connection, 'campaigns', [
            'create' => [
                'name' => $data['name'],
                'status' => 'PAUSED',
                'advertisingChannelType' => 'PERFORMANCE_MAX',
                'campaignBudget' => $budgetResource,
                ...$this->biddingSettings(
                    $data['bidding_strategy'] ?? 'max_conversions',
                    isset($data['bid_target']) ? (float) $data['bid_target'] : null,
                ),
                // No networkSettings: Performance Max places across every
                // network by definition, which is the whole of what it is.
                ...$this->scheduleSettings($data),
                ...$this->conversionSettings($data),
                'containsEuPoliticalAdvertising' => 'DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING',
            ],
        ]);
        $campaignResource = $this->firstResourceName($campaignResponse, 'campaignResult');
        if (! is_string($campaignResource)) {
            throw new RuntimeException('Google Ads did not return a Performance Max campaign.');
        }
        $this->addTargetingCriteria($connection, $campaignResource, $data);

        // After the create, because the goals do not exist until the
        // campaign does. Google makes one per conversion category on the
        // account and marks them all biddable, so without this a campaign
        // told to optimise for one conversion optimises for every one.
        $this->narrowConversionGoals($connection, $this->resourceId($campaignResource), $data);

        $assetGroupResponse = $this->mutate($connection, 'assetGroups', [
            'create' => [
                'campaign' => $campaignResource,
                'name' => $data['asset_group_name'],
                // Paused, as above. A Performance Max campaign has no ad to
                // pause separately, so the asset group is the only level below
                // the campaign there is.
                'status' => 'PAUSED',
                'finalUrls' => [$data['final_url']],
            ],
        ]);
        $assetGroupResource = $this->firstResourceName($assetGroupResponse, 'assetGroupResult');
        if (! is_string($assetGroupResource)) {
            throw new RuntimeException('Google Ads did not return a Performance Max asset group.');
        }

        $links = [];
        foreach ($data['headlines'] as $asset) {
            $links[] = ['assetGroup' => $assetGroupResource, 'asset' => $asset, 'fieldType' => 'HEADLINE'];
        }
        foreach ($data['long_headlines'] as $asset) {
            $links[] = ['assetGroup' => $assetGroupResource, 'asset' => $asset, 'fieldType' => 'LONG_HEADLINE'];
        }
        foreach ($data['descriptions'] as $asset) {
            $links[] = ['assetGroup' => $assetGroupResource, 'asset' => $asset, 'fieldType' => 'DESCRIPTION'];
        }
        foreach ($data['marketing_image_assets'] as $asset) {
            $links[] = ['assetGroup' => $assetGroupResource, 'asset' => $asset, 'fieldType' => 'MARKETING_IMAGE'];
        }
        foreach ($data['square_marketing_image_assets'] as $asset) {
            $links[] = ['assetGroup' => $assetGroupResource, 'asset' => $asset, 'fieldType' => 'SQUARE_MARKETING_IMAGE'];
        }
        foreach (($data['logo_image_assets'] ?? []) as $asset) {
            $links[] = ['assetGroup' => $assetGroupResource, 'asset' => $asset, 'fieldType' => 'LOGO'];
        }
        foreach (($data['youtube_video_assets'] ?? []) as $asset) {
            $links[] = ['assetGroup' => $assetGroupResource, 'asset' => $asset, 'fieldType' => 'YOUTUBE_VIDEO'];
        }
        $this->mutateMany($connection, 'assetGroupAssets', array_map(fn (array $link): array => ['create' => $link], $links));

        return [
            'budget_id' => $this->resourceId($budgetResource),
            'campaign_id' => $this->resourceId($campaignResource),
            'asset_group_id' => $this->resourceId($assetGroupResource),
            'ad_group_id' => null,
            'ad_id' => null,
        ];
    }

    public function createYouTubeVideoAsset(GoogleAdsConnection $connection, string $videoId, ?string $name = null): string
    {
        $response = $this->mutate($connection, 'assets', [
            'create' => [
                'name' => $name ?: 'YouTube video '.$videoId,
                'type' => 'YOUTUBE_VIDEO',
                'youtubeVideoAsset' => [
                    'youtubeVideoId' => $videoId,
                ],
            ],
        ]);

        $resource = $this->firstResourceName($response, 'assetResult');
        if (! is_string($resource)) {
            throw new RuntimeException('Google Ads did not return a YouTube video asset.');
        }

        return $resource;
    }

    public function createImageAsset(GoogleAdsConnection $connection, string $contents, string $name): string
    {
        $response = $this->mutate($connection, 'assets', [
            'create' => [
                'name' => $name,
                'type' => 'IMAGE',
                'imageAsset' => [
                    'data' => base64_encode($contents),
                ],
            ],
        ]);

        $resource = $this->firstResourceName($response, 'assetResult');
        if (! is_string($resource)) {
            throw new RuntimeException('Google Ads did not return an image asset.');
        }

        return $resource;
    }

    /**
     * The campaign's bidding, including the number it aims at.
     *
     * Accepts both the short names the Google chat used and the API's own
     * names, which is what the unified flow stores on the campaign because
     * that is the vocabulary GoogleRules offers the user.
     *
     * The target matters. This used to fall through to maximizeConversions for
     * anything it did not recognise, so a campaign set to TARGET_CPA with a
     * target the user chose was created bidding for volume at any price, and
     * nothing anywhere said the number had been dropped.
     *
     * @param  float|null  $target  cost per action, or the ROAS multiple
     * @return array<string,mixed>
     */
    /**
     * Change a published campaign, one field set at a time.
     *
     * Separate from updateCampaign() above, which belongs to the older Google
     * chat and takes its GoogleAdsCampaign model. This one is given the ids the
     * publisher recorded and the values already shaped for Google, so the
     * mapping from our fields stays in GooglePublisher next to the mapping that
     * created them.
     *
     * updateMask is not optional and is not a formality: Google clears any
     * field named in the mask and absent from the payload, so a mask wider than
     * the change would blank whatever else it covers.
     *
     * @param  array<string,mixed>  $fields  Google field names to values
     */
    public function updateLiveCampaign(GoogleAdsConnection $connection, string $campaignId, array $fields): void
    {
        if ($fields === []) {
            return;
        }

        $this->mutate($connection, 'campaigns', [
            'update' => [
                'resourceName' => 'customers/'.$this->customerId($connection).'/campaigns/'.$campaignId,
                ...$fields,
            ],
            'updateMask' => implode(',', array_keys($fields)),
        ]);
    }

    /**
     * Set the status of a campaign, its ad group and its ad together.
     *
     * Google calls live ENABLED rather than ACTIVE, and every level has its own:
     * an ENABLED campaign over a PAUSED ad group serves nothing and reports it as
     * a campaign with no impressions, which is indistinguishable from one that is
     * simply not winning any auctions.
     *
     * Three mutates rather than one, because they are three resource types.
     *
     * @param  array<string,mixed>  $ids  as recorded by createPausedCampaign
     */
    public function setLiveStatus(GoogleAdsConnection $connection, array $ids, string $status): void
    {
        $customerId = $this->customerId($connection);

        $levels = [
            'ads' => ['id' => $ids['ad_id'] ?? null, 'path' => 'adGroupAds'],
            'adGroups' => ['id' => $ids['ad_group_id'] ?? null, 'path' => 'adGroups'],
            'campaigns' => ['id' => $ids['campaign_id'] ?? null, 'path' => 'campaigns'],
        ];

        foreach ($levels as $resource => $level) {
            if (blank($level['id'])) {
                continue;
            }

            $this->mutate($connection, $resource, [
                'update' => [
                    'resourceName' => sprintf(
                        'customers/%s/%s/%s',
                        $customerId,
                        $level['path'],
                        $level['id'],
                    ),
                    'status' => $status,
                ],
                'updateMask' => 'status',
            ]);
        }
    }

    /** The money lives on its own resource, so it is its own call. */
    public function updateLiveBudget(GoogleAdsConnection $connection, string $budgetId, float $daily): void
    {
        $this->mutate($connection, 'campaignBudgets', [
            'update' => [
                'resourceName' => 'customers/'.$this->customerId($connection).'/campaignBudgets/'.$budgetId,
                'amountMicros' => (string) $this->micros($daily),
            ],
            'updateMask' => 'amountMicros',
        ]);
    }

    /**
     * The network block for a set of chosen networks, for callers outside.
     *
     * @param  list<string>  $networks
     * @param  array<string,bool>  $default
     * @return array<string,bool>
     */
    public function networksFor(array $networks, array $default): array
    {
        return $this->networkSettings(['networks' => $networks], $default);
    }

    /**
     * The bidding block for a strategy, for callers outside this class.
     *
     * Exposed so an update sends the same shape as the create did rather than a
     * second reading of the same rules.
     *
     * @return array<string,mixed>
     */
    public function biddingFor(string $strategy, ?float $target = null): array
    {
        return $this->biddingSettings($strategy, $target);
    }

    private function biddingSettings(string $strategy, ?float $target = null): array
    {
        return match (strtolower($strategy)) {
            'target_cpa', 'target_cpa_bidding' => [
                'targetCpa' => ['targetCpaMicros' => (string) $this->micros($target)],
            ],
            'target_roas' => [
                // A ROAS target is a multiple, not money: 4.0 means four times
                // the spend back. Sent as a plain double rather than micros.
                'targetRoas' => ['targetRoas' => (float) ($target ?? 1.0)],
            ],
            'max_conversion_value', 'maximize_conversion_value' => ['maximizeConversionValue' => (object) []],
            'max_clicks', 'maximize_clicks' => ['targetSpend' => (object) []],
            'manual_cpc' => ['manualCpc' => ['enhancedCpcEnabled' => false]],
            default => ['maximizeConversions' => (object) []],
        };
    }

    /**
     * Which networks the campaign is allowed to run on.
     *
     * These were hardcoded per campaign type, so the networks a buyer picked
     * were collected, shown back to them in the panel, and then thrown away: a
     * campaign told to run on Display only was created on Search only.
     *
     * The defaults stand when nothing was chosen, which is what every campaign
     * built before the question existed relies on.
     *
     * @param  array<string,mixed>  $data
     * @param  array<string,bool>  $default
     * @return array<string,bool>
     */
    private function networkSettings(array $data, array $default): array
    {
        $chosen = $this->targetingValues($data['networks'] ?? null);

        if ($chosen === []) {
            return $default;
        }

        return [
            'targetGoogleSearch' => in_array('search', $chosen, true),
            'targetSearchNetwork' => in_array('search_partners', $chosen, true),
            'targetContentNetwork' => in_array('display', $chosen, true),
            'targetPartnerSearchNetwork' => false,
        ];
    }

    /**
     * When the campaign runs, if anybody said.
     *
     * Dates were never sent at all, so a campaign given an end date ran until
     * somebody noticed. Google takes them as plain YYYYMMDD.
     *
     * @param  array<string,mixed>  $data
     * @return array<string,string>
     */
    private function scheduleSettings(array $data): array
    {
        return array_filter([
            'startDate' => filled($data['starts_at'] ?? null)
                ? date('Ymd', strtotime((string) $data['starts_at'])) : null,
            'endDate' => filled($data['ends_at'] ?? null)
                ? date('Ymd', strtotime((string) $data['ends_at'])) : null,
        ], fn (?string $value): bool => $value !== null);
    }

    /**
     * What the campaign counts as a conversion.
     *
     * Nothing set this. The flow asks for a conversion action, the publish gate
     * refuses a sales or leads campaign without one, and then it went nowhere:
     * the campaign bid toward conversions while telling Google nothing about
     * which conversions to count.
     *
     * selectiveOptimization is the campaign level field for it, which is what
     * makes the answer actually reach the thing being optimised.
     *
     * @param  array<string,mixed>  $data
     * @return array<string,mixed>
     */
    /**
     * Nothing on the campaign itself, which is the point.
     *
     * This once sent selectiveOptimization naming the chosen conversion action,
     * on Search, Display and Performance Max alike. Google refuses it outright
     * on Search: publishing campaign 54 on 23 Sep failed the campaign create
     * with OPERATION_NOT_PERMITTED_FOR_CONTEXT and named no field. Selective
     * optimization belongs to App campaigns.
     *
     * A Search campaign says which conversions it counts through its conversion
     * goals, which are separate resources and cannot be set until the campaign
     * exists. See narrowConversionGoals(), called straight after the create.
     *
     * @param  array<string,mixed>  $data
     * @return array<string,mixed>
     */
    private function conversionSettings(array $data): array
    {
        return [];
    }

    /**
     * Count only the conversion the buyer chose.
     *
     * Google gives a new campaign one conversion goal per category that has a
     * conversion action on the account, every one of them biddable, so a
     * campaign told to optimise for "Book Demo" also optimises for "Purchase"
     * and every other category configured. That is the account's setting
     * leaking into a campaign that was asked a question about itself.
     *
     * The goals cannot be created, only flipped: Google makes them with the
     * campaign and the resource name is deterministic, {campaignId}~{CATEGORY}~
     * {ORIGIN}. Narrowing is therefore setting biddable false on every category
     * that is not the chosen one. Confirmed against the live account: the
     * update is accepted and the goal reads back with biddable absent, which is
     * how Google says false.
     *
     * Everything is left alone when the chosen action's category cannot be
     * found. A campaign counting more conversions than asked still spends
     * sensibly; one counting none at all cannot optimise toward anything.
     *
     * @param  array<string,mixed>  $data
     */
    private function narrowConversionGoals(GoogleAdsConnection $connection, string $campaignId, array $data): void
    {
        $action = (string) ($data['conversion_action'] ?? '');

        if ($action === '') {
            return;
        }

        $category = $this->conversionActionCategory($connection, $action);

        if ($category === null) {
            return;
        }

        $customerId = $this->customerId($connection);

        $goals = $this->search(
            $connection,
            'SELECT campaign_conversion_goal.category, campaign_conversion_goal.origin '
            .'FROM campaign_conversion_goal WHERE campaign.id = '.$campaignId,
            $customerId,
        );

        $operations = [];

        foreach (data_get($goals, '0.results', []) as $goal) {
            $goalCategory = (string) data_get($goal, 'campaignConversionGoal.category');
            $origin = (string) data_get($goal, 'campaignConversionGoal.origin');

            if ($goalCategory === '' || $origin === '' || $goalCategory === $category) {
                continue;
            }

            $operations[] = ['update' => [
                'resourceName' => sprintf(
                    'customers/%s/campaignConversionGoals/%s~%s~%s',
                    $customerId,
                    $campaignId,
                    $goalCategory,
                    $origin,
                ),
                'biddable' => false,
            ], 'updateMask' => 'biddable'];
        }

        if ($operations !== []) {
            $this->mutateMany($connection, 'campaignConversionGoals', $operations);
        }
    }

    /** The category of one conversion action, or null if it is not on the account. */
    private function conversionActionCategory(GoogleAdsConnection $connection, string $resourceName): ?string
    {
        foreach ($this->conversionActionOptions($connection) as $option) {
            if (($option['resource_name'] ?? null) === $resourceName) {
                return $option['category'] ?? null;
            }
        }

        return null;
    }

    /** Google takes money in millionths of the account's currency unit. */
    private function micros(?float $amount): int
    {
        return (int) round((float) $amount * 1_000_000);
    }

    /**
     * Remove something we created, for a rollback.
     *
     * Google's mutate takes a resource name rather than a bare id, so the
     * caller passes back what create returned. Never throws: a rollback that
     * fails must leave the original error as the one the user is told about.
     */
    public function removeResource(GoogleAdsConnection $connection, string $resource, string $collection): void
    {
        try {
            $this->mutate($connection, $collection, ['remove' => $resource]);
        } catch (\Throwable $e) {
            report($e);
        }
    }

    /**
     * The Google device types this campaign should not reach.
     *
     * Empty when every device was chosen, or when nobody chose: Google's
     * default is all of them, so excluding nothing is the right no-op rather
     * than something to guess at.
     *
     * @param  array<string,mixed>  $data
     * @return list<string>
     */
    private function devicesToExclude(array $data): array
    {
        $chosen = array_map('strtolower', $this->targetingValues($data['devices'] ?? null));

        if ($chosen === []) {
            return [];
        }

        $wanted = [
            'MOBILE' => in_array('mobile', $chosen, true),
            'TABLET' => in_array('mobile', $chosen, true),
            'DESKTOP' => in_array('desktop', $chosen, true),
        ];

        // Every device unwanted means the answer was not understood, and a
        // campaign excluded from everything reaches nobody. Better to target
        // all of them than to silently build one that cannot deliver.
        if (! in_array(true, $wanted, true)) {
            return [];
        }

        return array_keys(array_filter($wanted, fn (bool $keep): bool => ! $keep));
    }

    private function addTargetingCriteria(GoogleAdsConnection $connection, string $campaignResource, array $data): void
    {
        $operations = [];
        $locationScope = (string) ($data['location_scope'] ?? '');
        $locations = $this->targetingValues($data['locations'] ?? null);
        if ($locations === [] && $locationScope !== '') {
            $locations = match ($locationScope) {
                'us_ca' => ['US', 'CA'],
                'us' => ['US'],
                'all' => [],
                default => $locations,
            };
        }

        foreach ($locations as $location) {
            $resource = str_starts_with($location, 'geoTargetConstants/')
                ? $location
                : $this->targetConstant($connection, 'geo_target_constant', $location);
            if ($resource !== null) {
                $operations[] = ['create' => [
                    'campaign' => $campaignResource,
                    'location' => ['geoTargetConstant' => $resource],
                ]];
            }
        }

        // Devices, which were collected by the chat and thrown away here.
        //
        // Google does not target devices, it un-targets them: every campaign
        // reaches desktop, mobile and tablet from the moment it exists, and the
        // only way to narrow that is a bid modifier of 0 on the ones not
        // wanted. Confirmed against the live account rather than assumed: a
        // criterion carrying "negative": true is refused with IMMUTABLE_FIELD,
        // and the same criterion with "bidModifier": 0 is accepted.
        //
        // Tablets go with mobile. Our vocabulary has only the two, Meta groups
        // them that way, and a buyer asking for mobile means a phone in a hand
        // rather than a phone and not a tablet. Connected TV is left alone: it
        // is outside the two words anyone chose between, and excluding it would
        // be a decision nobody made.
        foreach ($this->devicesToExclude($data) as $device) {
            $operations[] = ['create' => [
                'campaign' => $campaignResource,
                'device' => ['type' => $device],
                'bidModifier' => 0,
            ]];
        }

        foreach ($this->targetingValues($data['languages'] ?? null) as $language) {
            $resource = str_starts_with($language, 'languageConstants/')
                ? $language
                : $this->targetConstant($connection, 'language_constant', $language);
            if ($resource !== null) {
                $operations[] = ['create' => [
                    'campaign' => $campaignResource,
                    'language' => ['languageConstant' => $resource],
                ]];
            }
        }
        if ($operations !== []) {
            $this->mutateMany($connection, 'campaignCriteria', $operations);
        }
    }

    private function targetingValues(mixed $value): array
    {
        if (is_array($value)) {
            return array_values(array_filter(array_map(fn ($entry): string => trim((string) $entry), $value)));
        }

        if (blank($value)) {
            return [];
        }

        return array_values(array_filter(array_map('trim', explode(',', (string) $value))));
    }

    /**
     * A value going inside single quotes in a GAQL query.
     *
     * The backslash is escaped as well as the quote, and in one pass.
     *
     * This escaped the quote and left the escape character alone, so a name
     * ending in a backslash-quote closed the literal anyway: the \\' became an
     * escaped backslash followed by a live quote, and everything after it was
     * parsed as GAQL against the live ad account. Locations and languages reach
     * here from the chat, so the value is a stranger's.
     *
     * strtr rather than str_replace with arrays, because str_replace applies
     * its pairs in sequence and the second pass would escape the backslashes
     * the first had just written. GAQL has no bind parameters, so this is the
     * whole defence.
     */
    private function escapeGaqlLiteral(string $value): string
    {
        return strtr($value, ['\\' => '\\\\', "'" => "\\'"]);
    }

    private function targetConstant(GoogleAdsConnection $connection, string $resourceType, string $name): ?string
    {
        $escapedName = $this->escapeGaqlLiteral($name);
        $field = $resourceType === 'geo_target_constant'
            ? 'geo_target_constant.resource_name'
            : 'language_constant.resource_name';
        $response = $this->search($connection, "SELECT {$field} FROM {$resourceType} WHERE {$resourceType}.name = '{$escapedName}'");
        $resultKey = $resourceType === 'geo_target_constant' ? 'geoTargetConstant' : 'languageConstant';

        return data_get($response, '0.results.0.'.$resultKey.'.resourceName');
    }

    /** @return list<array{resource_name: string, name: string, category: string, type: string}> */
    public function conversionActionOptions(GoogleAdsConnection $connection): array
    {
        $response = $this->search($connection, 'SELECT conversion_action.resource_name, conversion_action.name, conversion_action.category, conversion_action.type FROM conversion_action WHERE conversion_action.status = ENABLED');

        return collect($response)
            ->flatMap(fn (array $chunk): array => $chunk['results'] ?? [])
            ->map(function (array $result): ?array {
                $action = $result['conversionAction'] ?? [];
                $resourceName = $action['resourceName'] ?? null;
                if (! is_string($resourceName) || $resourceName === '') {
                    return null;
                }

                return [
                    'resource_name' => $resourceName,
                    'name' => (string) ($action['name'] ?? 'Unnamed conversion action'),
                    'category' => (string) ($action['category'] ?? 'UNSPECIFIED'),
                    'type' => (string) ($action['type'] ?? 'UNSPECIFIED'),
                ];
            })
            ->filter()
            ->values()
            ->all();
    }

    /**
     * Daily campaign metrics for performance ingest. Does not mutate.
     *
     * @return list<array<string, mixed>>
     */
    public function readCampaignMetrics(
        GoogleAdsConnection $connection,
        string $googleCampaignId,
        string $since,
        string $until,
    ): array {
        $campaignId = preg_replace('/\D/', '', $googleCampaignId) ?? '';

        if ($campaignId === '') {
            throw new RuntimeException('Google campaign id is missing.');
        }

        if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $since) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $until)) {
            throw new RuntimeException('Invalid metrics date range.');
        }

        $query = <<<GAQL
SELECT
  campaign.id,
  campaign.name,
  campaign.status,
  segments.date,
  metrics.impressions,
  metrics.clicks,
  metrics.cost_micros,
  metrics.ctr,
  metrics.average_cpc
FROM campaign
WHERE campaign.id = {$campaignId}
  AND segments.date BETWEEN '{$since}' AND '{$until}'
GAQL;

        $response = $this->search($connection, $query);

        return collect($response)
            ->flatMap(fn (mixed $chunk): array => is_array($chunk) ? ($chunk['results'] ?? []) : [])
            ->values()
            ->all();
    }

    /**
     * Hourly performance for a live Google campaign. Does not mutate.
     *
     * @return list<array<string, mixed>>
     */
    public function readCampaignHourlyMetrics(
        GoogleAdsConnection $connection,
        string $googleCampaignId,
        string $since,
        string $until,
    ): array {
        $campaignId = preg_replace('/\D/', '', $googleCampaignId) ?? '';

        if ($campaignId === '') {
            throw new RuntimeException('Google campaign id is missing.');
        }

        if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $since) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $until)) {
            throw new RuntimeException('Invalid metrics date range.');
        }

        $query = <<<GAQL
SELECT
  campaign.id,
  campaign.name,
  campaign.status,
  segments.date,
  segments.hour,
  metrics.impressions,
  metrics.clicks,
  metrics.cost_micros,
  metrics.ctr,
  metrics.average_cpc
FROM campaign
WHERE campaign.id = {$campaignId}
  AND segments.date BETWEEN '{$since}' AND '{$until}'
GAQL;

        $response = $this->search($connection, $query);

        return collect($response)
            ->flatMap(fn (mixed $chunk): array => is_array($chunk) ? ($chunk['results'] ?? []) : [])
            ->values()
            ->all();
    }

    /**
     * Platform start date for a Google campaign. Does not mutate.
     */
    public function readCampaignStartDate(GoogleAdsConnection $connection, string $googleCampaignId): ?string
    {
        $campaignId = preg_replace('/\D/', '', $googleCampaignId) ?? '';

        if ($campaignId === '') {
            return null;
        }

        $query = <<<GAQL
SELECT
  campaign.id,
  campaign.start_date
FROM campaign
WHERE campaign.id = {$campaignId}
GAQL;

        $row = collect($this->search($connection, $query))
            ->flatMap(fn (mixed $chunk): array => is_array($chunk) ? ($chunk['results'] ?? []) : [])
            ->first();

        $date = data_get($row, 'campaign.startDate') ?? data_get($row, 'campaign.start_date');

        return is_string($date) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1
            ? $date
            : null;
    }

    /**
     * Campaign metadata for performance profiles. Does not mutate.
     *
     * @return array<string, mixed>
     */
    public function readCampaignProfile(GoogleAdsConnection $connection, string $googleCampaignId, bool $fresh = false): array
    {
        $campaignId = preg_replace('/\D/', '', $googleCampaignId) ?? '';

        if ($campaignId === '') {
            throw new RuntimeException('Google campaign id is missing.');
        }

        $query = <<<GAQL
SELECT
  campaign.id,
  campaign.name,
  campaign.status,
  campaign.advertising_channel_type,
  campaign.start_date,
  campaign.primary_status,
  campaign.primary_status_reasons,
  campaign_budget.resource_name,
  campaign_budget.reference_count,
  campaign_budget.amount_micros
FROM campaign
WHERE campaign.id = {$campaignId}
GAQL;

        $row = collect($this->search($connection, $query, fresh: $fresh))
            ->flatMap(fn (mixed $chunk): array => is_array($chunk) ? ($chunk['results'] ?? []) : [])
            ->first();

        if (! is_array($row)) {
            throw new RuntimeException('Google campaign profile could not be loaded.');
        }

        $amountMicros = data_get($row, 'campaignBudget.amountMicros')
            ?? data_get($row, 'campaign_budget.amount_micros');

        return [
            'id' => (string) (data_get($row, 'campaign.id') ?? $campaignId),
            'name' => (string) data_get($row, 'campaign.name', ''),
            'status' => (string) data_get($row, 'campaign.status', ''),
            'advertising_channel_type' => (string) (data_get($row, 'campaign.advertisingChannelType')
                ?? data_get($row, 'campaign.advertising_channel_type')
                ?? ''),
            'start_date' => (string) (data_get($row, 'campaign.startDate')
                ?? data_get($row, 'campaign.start_date')
                ?? ''),
            'primary_status' => (string) (data_get($row, 'campaign.primaryStatus')
                ?? data_get($row, 'campaign.primary_status')
                ?? ''),
            'primary_status_reasons' => data_get($row, 'campaign.primaryStatusReasons')
                ?? data_get($row, 'campaign.primary_status_reasons')
                ?? [],
            'daily_budget' => is_numeric($amountMicros)
                ? round(((float) $amountMicros) / 1_000_000, 2)
                : null,
            'budget_resource' => (string) (data_get($row, 'campaignBudget.resourceName')
                ?? data_get($row, 'campaign_budget.resource_name')
                ?? ''),
            'budget_reference_count' => ($referenceCount = data_get($row, 'campaignBudget.referenceCount')
                ?? data_get($row, 'campaign_budget.reference_count')) !== null
                ? (int) $referenceCount
                : null,
        ];
    }

    /**
     * Location targets attached to a campaign. Does not mutate.
     *
     * @return list<string>
     */
    public function readCampaignGeoTargets(GoogleAdsConnection $connection, string $googleCampaignId): array
    {
        $campaignId = preg_replace('/\D/', '', $googleCampaignId) ?? '';

        if ($campaignId === '') {
            throw new RuntimeException('Google campaign id is missing.');
        }

        $query = <<<GAQL
SELECT
  geo_target_constant.name,
  geo_target_constant.country_code,
  geo_target_constant.canonical_name
FROM campaign_criterion
WHERE campaign.id = {$campaignId}
  AND campaign_criterion.type = 'LOCATION'
  AND campaign_criterion.status != 'REMOVED'
GAQL;

        return collect($this->search($connection, $query))
            ->flatMap(fn (mixed $chunk): array => is_array($chunk) ? ($chunk['results'] ?? []) : [])
            ->map(function (array $row): ?string {
                $name = trim((string) (data_get($row, 'geoTargetConstant.canonicalName')
                    ?? data_get($row, 'geo_target_constant.canonical_name')
                    ?? data_get($row, 'geoTargetConstant.name')
                    ?? data_get($row, 'geo_target_constant.name')
                    ?? ''));

                if ($name !== '') {
                    return $name;
                }

                $country = trim((string) (data_get($row, 'geoTargetConstant.countryCode')
                    ?? data_get($row, 'geo_target_constant.country_code')
                    ?? ''));

                return $country !== '' ? $country : null;
            })
            ->filter()
            ->unique()
            ->values()
            ->all();
    }

    /**
     * Read the exact included and excluded location criteria for adoption.
     * Unknown geographic types remain explicit in the imported baseline.
     *
     * @return list<array{criterion: string, geo_target: string, country_code: string, target_type: string, name: string, negative: bool}>
     */
    public function readCampaignLocationCriteria(GoogleAdsConnection $connection, string $googleCampaignId): array
    {
        if (! preg_match('/^[0-9]+$/D', $googleCampaignId)) {
            throw new RuntimeException('Google campaign id must be numeric.');
        }

        $unsupportedQuery = <<<GAQL
SELECT campaign_criterion.type
FROM campaign_criterion
WHERE campaign.id = {$googleCampaignId}
  AND campaign_criterion.type IN ('PROXIMITY', 'LOCATION_GROUP')
  AND campaign_criterion.status != 'REMOVED'
GAQL;

        foreach ($this->search($connection, $unsupportedQuery) as $chunk) {
            if (($chunk['results'] ?? []) !== []) {
                throw new RuntimeException('This Google campaign uses proximity or location-group targeting, which cannot be imported safely yet.');
            }
        }

        $query = <<<GAQL
SELECT
  campaign_criterion.resource_name,
  campaign_criterion.location.geo_target_constant,
  campaign_criterion.negative,
  geo_target_constant.country_code,
  geo_target_constant.target_type,
  geo_target_constant.canonical_name
FROM campaign_criterion
WHERE campaign.id = {$googleCampaignId}
  AND campaign_criterion.type = 'LOCATION'
  AND campaign_criterion.status != 'REMOVED'
GAQL;

        return collect($this->search($connection, $query))
            ->flatMap(fn (mixed $chunk): array => is_array($chunk) ? ($chunk['results'] ?? []) : [])
            ->map(function (array $row): array {
                $criterion = (string) (data_get($row, 'campaignCriterion.resourceName')
                    ?? data_get($row, 'campaign_criterion.resource_name') ?? '');
                $geoTarget = (string) (data_get($row, 'campaignCriterion.location.geoTargetConstant')
                    ?? data_get($row, 'campaign_criterion.location.geo_target_constant') ?? '');

                if ($criterion === '' || ! preg_match('/^geoTargetConstants\/[0-9]+$/D', $geoTarget)) {
                    throw new RuntimeException('Google returned a location criterion without a usable identifier; import was cancelled.');
                }

                return [
                    'criterion' => $criterion,
                    'geo_target' => $geoTarget,
                    'country_code' => strtoupper((string) (data_get($row, 'geoTargetConstant.countryCode')
                        ?? data_get($row, 'geo_target_constant.country_code') ?? '')),
                    'target_type' => (string) (data_get($row, 'geoTargetConstant.targetType')
                        ?? data_get($row, 'geo_target_constant.target_type') ?? ''),
                    'name' => (string) (data_get($row, 'geoTargetConstant.canonicalName')
                        ?? data_get($row, 'geo_target_constant.canonical_name') ?? ''),
                    'negative' => (bool) (data_get($row, 'campaignCriterion.negative')
                        ?? data_get($row, 'campaign_criterion.negative') ?? false),
                ];
            })
            ->values()
            ->all();
    }

    /**
     * Resolve a targetable country by ISO code, never by an ambiguous name.
     *
     * @return array{id: string, name: string}|null
     */
    public function countryGeoTarget(GoogleAdsConnection $connection, string $countryCode, ?string $customerId = null): ?array
    {
        if (! preg_match('/^[A-Z]{2}$/D', $countryCode)) {
            throw new \InvalidArgumentException('Country code must contain two uppercase letters.');
        }

        $query = "SELECT geo_target_constant.resource_name, geo_target_constant.name, geo_target_constant.country_code, geo_target_constant.target_type, geo_target_constant.status FROM geo_target_constant WHERE geo_target_constant.country_code = '{$countryCode}' AND geo_target_constant.target_type = 'Country' AND geo_target_constant.status = 'ENABLED'";

        foreach ($this->search($connection, $query, $customerId) as $chunk) {
            foreach ((array) ($chunk['results'] ?? []) as $row) {
                $target = $row['geoTargetConstant'] ?? [];
                if (($target['countryCode'] ?? null) === $countryCode
                    && ($target['targetType'] ?? null) === 'Country'
                    && ($target['status'] ?? null) === 'ENABLED'
                    && filled($target['resourceName'] ?? null)) {
                    return ['id' => (string) $target['resourceName'], 'name' => (string) ($target['name'] ?? '')];
                }
            }
        }

        return null;
    }

    /**
     * Search targetable cities or first-level regions without guessing from a
     * name. Canonical names keep same-named places distinguishable.
     *
     * @return list<array{id: string, name: string, label: string, country_code: string, kind: string, provider_type: string, parent_id: string|null}>
     */
    public function geoTargetCandidates(GoogleAdsConnection $connection, string $kind, string $countryCode, string $term, ?string $customerId = null): array
    {
        $types = match ($kind) {
            'city' => ['City'],
            'region' => ['State', 'Province', 'Region', 'Territory'],
            default => throw new \InvalidArgumentException('Search cities or regions only.'),
        };

        if (! preg_match('/^[A-Z]{2}$/D', $countryCode)
            || ! preg_match('/^[\pL\pN .,-]{2,80}$/uD', trim($term))) {
            throw new \InvalidArgumentException('Provide a country code and a city or region name of 2 to 80 characters.');
        }

        $term = trim($term);
        $parts = array_map('trim', explode(',', $term, 2));

        if (count($parts) === 2 && (mb_strlen($parts[0]) < 2 || mb_strlen($parts[1]) < 2)) {
            throw new \InvalidArgumentException('Include a place name and a state or province after the comma.');
        }

        $name = $parts[0];
        $parentFilter = isset($parts[1]) ? " AND geo_target_constant.canonical_name LIKE '%{$parts[1]}%'" : '';
        $quotedTypes = implode(', ', array_map(fn (string $type): string => "'{$type}'", $types));
        $query = "SELECT geo_target_constant.resource_name, geo_target_constant.name, geo_target_constant.canonical_name, geo_target_constant.country_code, geo_target_constant.target_type, geo_target_constant.status, geo_target_constant.parent_geo_target FROM geo_target_constant WHERE geo_target_constant.country_code = '{$countryCode}' AND geo_target_constant.target_type IN ({$quotedTypes}) AND geo_target_constant.status = 'ENABLED' AND geo_target_constant.name LIKE '%{$name}%'{$parentFilter} ORDER BY geo_target_constant.canonical_name LIMIT 25";
        $candidates = [];

        foreach ($this->search($connection, $query, $customerId) as $chunk) {
            foreach ((array) ($chunk['results'] ?? []) as $row) {
                $target = $row['geoTargetConstant'] ?? [];

                if (($target['countryCode'] ?? null) !== $countryCode
                    || ($target['status'] ?? null) !== 'ENABLED'
                    || ! in_array($target['targetType'] ?? null, $types, true)
                    || blank($target['resourceName'] ?? null)
                    || blank($target['name'] ?? null)
                    || blank($target['canonicalName'] ?? null)) {
                    continue;
                }

                $candidates[] = [
                    'id' => (string) $target['resourceName'],
                    'name' => (string) ($target['name'] ?? ''),
                    'label' => (string) ($target['canonicalName'] ?? $target['name'] ?? ''),
                    'country_code' => $countryCode,
                    'kind' => $kind,
                    'provider_type' => (string) $target['targetType'],
                    'parent_id' => $target['parentGeoTarget'] ?? null,
                ];
            }
        }

        return array_slice($candidates, 0, 25);
    }

    private function search(GoogleAdsConnection $connection, string $query, ?string $customerId = null, bool $fresh = false): array
    {
        $customerId = $customerId !== null
            ? str_replace('-', '', $customerId)
            : $this->customerId($connection);
        $token = $this->accessToken($connection);
        $headers = [
            'developer-token' => GoogleAdsConnection::developerToken(),
            'Content-Type' => 'application/json',
        ];
        $loginCustomerId = $connection->manager_customer_id ?: config('services.google_ads.login_customer_id');
        if (filled($loginCustomerId)) {
            $headers['login-customer-id'] = str_replace('-', '', $loginCustomerId);
        }

        return $this->audited(
            'POST',
            'customers/'.$customerId.'/googleAds:searchStream',
            $customerId,
            ['query' => $query],
            mutating: false,
            fresh: $fresh,
            call: function () use ($token, $headers, $customerId, $query): array {
                $response = Http::withToken($token)
                    ->withHeaders($headers)
                    ->connectTimeout(5)
                    ->timeout(20)
                    ->post('https://googleads.googleapis.com/'.config('services.google_ads.api_version', 'v25').'/customers/'.$customerId.'/googleAds:searchStream', ['query' => $query]);

                if ($response->failed()) {
                    throw new RuntimeException((string) data_get($response->json(), 'error.message', 'Google Ads targeting could not be resolved.'));
                }

                return $response->json();
            },
        );
    }

    public function updateCampaign(GoogleAdsConnection $connection, GoogleAdsCampaign $campaign, array $data): void
    {
        $customerId = $this->customerId($connection);
        $this->mutate($connection, 'campaigns', [
            'update' => [
                'resourceName' => 'customers/'.$customerId.'/campaigns/'.$campaign->google_campaign_id,
                'name' => $data['name'],
                'status' => $this->status($data['status']),
            ],
            'updateMask' => 'name,status',
        ]);

        if ($campaign->google_campaign_budget_id && array_key_exists('daily_budget', $data) && $data['daily_budget'] !== null) {
            $this->mutate($connection, 'campaignBudgets', [
                'update' => [
                    'resourceName' => 'customers/'.$customerId.'/campaignBudgets/'.$campaign->google_campaign_budget_id,
                    'amountMicros' => (string) round(((float) $data['daily_budget']) * 1000000),
                ],
                'updateMask' => 'amountMicros',
            ]);
        }
    }

    private function mutate(GoogleAdsConnection $connection, string $resource, array $operation): array
    {
        $customerId = $this->customerId($connection);
        $token = $this->accessToken($connection);
        $headers = [
            'developer-token' => GoogleAdsConnection::developerToken(),
            'Content-Type' => 'application/json',
        ];
        $loginCustomerId = $connection->manager_customer_id ?: config('services.google_ads.login_customer_id');
        if (filled($loginCustomerId)) {
            $headers['login-customer-id'] = str_replace('-', '', $loginCustomerId);
        }

        return $this->audited(
            'POST',
            'customers/'.$customerId.'/'.$resource.':mutate',
            $customerId,
            ['operations' => [$operation]],
            mutating: true,
            call: fn (): array => $this->handleMutationResponse(
                Http::withToken($token)
                    ->withHeaders($headers)
                    ->connectTimeout(5)
                    ->timeout(20)
                    ->post('https://googleads.googleapis.com/'.config('services.google_ads.api_version', 'v25').'/customers/'.$customerId.'/'.$resource.':mutate', [
                        'operations' => [$operation],
                    ]),
            ),
        );
    }

    private function mutateMany(GoogleAdsConnection $connection, string $resource, array $operations): array
    {
        $customerId = $this->customerId($connection);
        $token = $this->accessToken($connection);
        $headers = [
            'developer-token' => GoogleAdsConnection::developerToken(),
            'Content-Type' => 'application/json',
        ];
        $loginCustomerId = $connection->manager_customer_id ?: config('services.google_ads.login_customer_id');
        if (filled($loginCustomerId)) {
            $headers['login-customer-id'] = str_replace('-', '', $loginCustomerId);
        }

        return $this->audited(
            'POST',
            'customers/'.$customerId.'/'.$resource.':mutate',
            $customerId,
            ['operations' => $operations],
            mutating: true,
            call: fn (): array => $this->handleMutationResponse(
                Http::withToken($token)
                    ->withHeaders($headers)
                    ->connectTimeout(5)
                    ->timeout(20)
                    ->post('https://googleads.googleapis.com/'.config('services.google_ads.api_version', 'v25').'/customers/'.$customerId.'/'.$resource.':mutate', [
                        'operations' => $operations,
                    ]),
            ),
        );
    }

    /**
     * A failed mutate, reported as Google reported it.
     *
     * Google answers with a generic top-level message - almost always "Request
     * contains an invalid argument." - and puts what is actually wrong in
     * `error.details[].errors[]`: a per-error `message` saying it in words, an
     * `errorCode` naming it, and a `location.fieldPathElements` saying which field
     * it is about. This kept the generic line and the enum, and threw the
     * sentences away, so a publish failed with "Request contains an invalid
     * argument. (DUPLICATE_CAMPAIGN_NAME)" when Google had said "The campaign name
     * is a duplicate" and named the field.
     *
     * The buyer reads this. It is the difference between a name they can change
     * and an error they can only forward to somebody.
     *
     * Kept on one line, because ChatController::readableFailure() takes the first
     * line of an exception message and a multi-line one would lose the rest.
     */
    private function handleMutationResponse($response): array
    {
        if (! $response->failed()) {
            return $response->json();
        }

        $errors = collect(data_get($response->json(), 'error.details', []))
            ->flatMap(fn (array $detail): array => $detail['errors'] ?? []);

        $codes = $errors
            ->pluck('errorCode')
            ->flatten()
            ->filter()
            ->unique()
            ->values();

        // The published-for-production case keeps its own explanation, because
        // Google's own wording for it says nothing a reader can act on.
        if ($codes->contains('CLOUD_PROJECT_NOT_APPROVED_FOR_PRODUCTION')) {
            throw new RuntimeException(
                'This Google Cloud project is not approved for production Google Ads accounts. '
                .'Use a Google Ads test account or request production access for the developer '
                .'token and OAuth project.'
            );
        }

        $reasons = $errors
            ->map(function (array $error): ?string {
                $said = trim((string) ($error['message'] ?? ''));

                if ($said === '') {
                    return null;
                }

                // Flattened, because ChatController::readableFailure() takes the
                // first line of an exception message and Google's own text is
                // sometimes several.
                $said = trim((string) preg_replace('/\s+/', ' ', $said));

                $field = collect(data_get($error, 'location.fieldPathElements', []))
                    ->pluck('fieldName')
                    ->filter()
                    ->implode('.');

                return $field === '' ? $said : rtrim($said, '.').' ['.$field.']';
            })
            ->filter()
            ->unique()
            ->values();

        $message = $reasons->isNotEmpty()
            ? 'Google refused it: '.$reasons->implode('; ')
            : (string) data_get($response->json(), 'error.message', 'Google Ads API request failed.');

        if ($codes->isNotEmpty()) {
            $message .= ' ('.$codes->implode(', ').')';
        }

        throw new RuntimeException($message);
    }

    private function accessToken(GoogleAdsConnection $connection): string
    {
        $refreshToken = filled($connection->refresh_token)
            ? $connection->refresh_token
            : (string) config('services.google_ads.refresh_token');

        if (blank($refreshToken)) {
            throw new RuntimeException('Google OAuth token refresh failed. Reconnect the Google Ads account.');
        }

        $response = Http::asForm()->connectTimeout(5)->timeout(10)->post('https://oauth2.googleapis.com/token', [
            'client_id' => GoogleAdsConnection::oauthClientId(),
            'client_secret' => GoogleAdsConnection::oauthClientSecret(),
            'refresh_token' => $refreshToken,
            'grant_type' => 'refresh_token',
        ]);

        if ($response->failed() || ! is_string($response->json('access_token'))) {
            throw new RuntimeException('Google OAuth token refresh failed. Reconnect the Google Ads account.');
        }

        return $response->json('access_token');
    }

    private function customerId(GoogleAdsConnection $connection): string
    {
        if (blank($connection->customer_id) || blank($connection->refresh_token)) {
            throw new RuntimeException('Connect Google Ads and select a customer account first.');
        }

        return str_replace('-', '', $connection->customer_id);
    }

    private function firstResourceName(array $response, string $resultKey): ?string
    {
        $mutateResponses = data_get($response, 'mutateOperationResponses', []);
        if (is_array($mutateResponses) && $mutateResponses !== []) {
            $resourceName = data_get($mutateResponses[0], $resultKey.'.resourceName');
            if (is_string($resourceName) && $resourceName !== '') {
                return $resourceName;
            }
        }

        $results = data_get($response, 'results', []);
        if (is_array($results) && $results !== []) {
            $resourceName = data_get($results[0], 'resourceName');
            if (is_string($resourceName) && $resourceName !== '') {
                return $resourceName;
            }
        }

        return null;
    }

    private function resourceId(?string $resourceName): string
    {
        if (! is_string($resourceName) || ! preg_match('/(?:\/|~)(\d+)$/', $resourceName, $matches)) {
            throw new RuntimeException('Google Ads returned an incomplete campaign response.');
        }

        return $matches[1];
    }

    private function status(string $status): string
    {
        return $status === 'enabled' ? 'ENABLED' : 'PAUSED';
    }
}
