<?php

namespace App\Campaigns\Platforms;

use App\Models\Campaign;
use App\Models\GoogleAdsConnection;
use Illuminate\Support\Facades\Storage;

/**
 * Google Ads, as the API actually enforces it.
 *
 * Google disagrees with Meta about almost everything a campaign is made of, and
 * until this class existed those disagreements were invisible: Google inherited
 * UnsupportedPlatformRules, which answers every question with Meta's answer or
 * with nothing. A Google campaign was therefore offered Meta's objectives, Meta's
 * bid strategies and Meta's placements, and the publish gate asked it for a
 * Facebook Page.
 *
 * The vocabulary here is Google's own, not a translation of Meta's. The brief
 * keeps the outcome in plain words and {@see objectiveFor()} is what turns it
 * into this platform's spelling, which is the only place the two vocabularies
 * are allowed to meet.
 */
class GoogleRules implements PlatformRules
{
    /**
     * What a responsive search ad holds, as Google enforces it.
     *
     * The minimums were already checked below. The maximums were not, and
     * publishing campaign 47 on 23 Sep failed with "Request contains an invalid
     * argument. (TOO_MANY)": three ads, each contributing its description and
     * its primary text, made six descriptions where Google takes four. The
     * error names no field, so nothing in it pointed at the copy.
     *
     * These are not a block. The assets of a responsive search ad are
     * alternatives that Google mixes per auction rather than a fixed set, so
     * sending the first four is fitting the format, not dropping a message.
     */
    /**
     * The fewest a responsive search ad will take.
     *
     * Checked when the copy is written rather than only at the gate, so a
     * single variation is refused while the model still has the brief in
     * hand instead of at publish, when the campaign already exists.
     */
    public const MIN_HEADLINES = 3;

    public const MAX_HEADLINES = 15;

    public const MAX_DESCRIPTIONS = 4;

    public function key(): string
    {
        return 'google';
    }

    public function name(): string
    {
        return 'Google Ads';
    }

    /**
     * The campaign goals Google offers for the channel types this product builds.
     *
     * App promotion and local store visits are deliberately absent, for the same
     * reason OUTCOME_APP_PROMOTION is absent on Meta: both need objects this
     * product has no field for anywhere, and a goal that cannot reach a
     * publishable campaign is worse as a button than as an absence.
     *
     * @return list<string>
     */
    public function objectives(): array
    {
        return ['SALES', 'LEADS', 'WEBSITE_TRAFFIC', 'BRAND_AWARENESS_AND_REACH'];
    }

    /**
     * The brief's plain word in Google's spelling.
     *
     * Only sales and leads are suggested from a landing page today, so the rest
     * are here to keep the mapping total rather than to be guessed at.
     */
    public function objectiveFor(string $outcome): ?string
    {
        return match (strtolower($outcome)) {
            'sales' => 'SALES',
            'leads' => 'LEADS',
            'traffic' => 'WEBSITE_TRAFFIC',
            'awareness' => 'BRAND_AWARENESS_AND_REACH',
            default => null,
        };
    }

    /** @return array{label: string, hint: string} */
    public function describeObjective(string $objective): array
    {
        return match ($objective) {
            'SALES' => ['label' => 'Sales', 'hint' => 'Optimise for purchases, counted by a conversion action.'],
            'LEADS' => ['label' => 'Leads', 'hint' => 'Optimise for form fills and enquiries, counted by a conversion action.'],
            'WEBSITE_TRAFFIC' => ['label' => 'Website traffic', 'hint' => 'Buy clicks to the page. No conversion tracking required.'],
            'BRAND_AWARENESS_AND_REACH' => ['label' => 'Awareness and reach', 'hint' => 'Buy impressions rather than clicks or conversions.'],
            default => ['label' => $objective, 'hint' => ''],
        };
    }

    /**
     * The campaign types this product can actually build on Google.
     *
     * Search first only because it is the cheapest to answer: it needs nothing
     * but words, all of which the shared flow already collects. Which one is
     * recommended is not this list's job, it is suggestedCampaignType()'s, and
     * it depends on the campaign.
     *
     * Shopping is absent: it needs a Merchant Center feed this product has no
     * concept of. Video is not a type here, it is a Performance Max campaign
     * carrying a YouTube asset, which is how Google sells it.
     *
     * @return list<string>
     */
    public function campaignTypes(): array
    {
        return ['search', 'display', 'performance_max'];
    }

    /** @return array{label: string, hint: string} */
    public function describeCampaignType(string $type): array
    {
        return match ($type) {
            'search' => [
                'label' => 'Search',
                'hint' => 'Text ads against what people search for. Built from your page, so nothing else is needed.',
            ],
            'display' => [
                'label' => 'Display',
                'hint' => 'Image ads across Google\'s network. Needs creatives at 1200x628 and 1200x1200.',
            ],
            'performance_max' => [
                'label' => 'Performance Max',
                'hint' => 'Google places across every network at once. Needs the same images as Display, and gives up most targeting control.',
            ],
            default => ['label' => $type, 'hint' => ''],
        };
    }

    /**
     * Which type suits this campaign, read from what it is already trying to do.
     *
     * Search was suggested unconditionally, which is a default rather than a
     * recommendation, and a wrong one often enough to matter: an awareness
     * campaign was being pointed at the search network, which sells intent and
     * has no impression inventory to buy reach against.
     *
     * Nothing here suggests a type the campaign could not actually publish.
     * Display and Performance Max both need images at two exact sizes, so
     * neither is recommended until those images are attached; recommending one
     * and then refusing it at the gate is worse than not recommending it.
     *
     * @return array{type: string, because: string}|null
     */
    public function suggestedCampaignType(?Campaign $campaign = null): ?array
    {
        if ($campaign === null) {
            return null;
        }

        $hasImages = $this->hasImageSized($campaign, 1200, 628)
            && $this->hasImageSized($campaign, 1200, 1200);

        // Reach is bought on impressions, and the search network does not sell
        // them: nobody searches in order to be made aware of something.
        if ($campaign->objective === 'BRAND_AWARENESS_AND_REACH') {
            return $hasImages
                ? ['type' => 'display', 'because' => 'awareness is bought on impressions, which the search network does not sell, and the creatives for it are already attached']
                : ['type' => 'display', 'because' => 'awareness is bought on impressions, which the search network does not sell. It needs creatives at 1200x628 and 1200x1200'];
        }

        // Everything a Performance Max campaign eats is already here: the copy,
        // the images at both sizes, and something to count. Below that bar it is
        // a campaign that hands Google control in exchange for less to work with.
        if ($hasImages
            && filled($campaign->pixel_id)
            && in_array($campaign->objective, ['SALES', 'LEADS'], true)) {
            return [
                'type' => 'performance_max',
                'because' => 'the copy, the images at both sizes and a conversion action are all in place, which is everything it needs to place across every network',
            ];
        }

        return [
            'type' => 'search',
            'because' => 'the landing page gives the keywords and the copy, which is everything a search campaign needs',
        ];
    }

    /**
     * Google's Smart Bidding strategies, plus manual CPC.
     *
     * Named as the API names them, because these are the values sent on the
     * campaign. Portfolio strategies are absent: they are account level objects
     * shared between campaigns, and this product creates campaigns.
     *
     * @return list<string>
     */
    /**
     * Google is the reason this takes an objective.
     *
     * "Bid automatically" means maximise clicks on a traffic campaign and
     * maximise conversions on a conversion one, and sending the wrong one buys
     * the wrong thing at full price. The others are unambiguous: a target cost
     * is a target CPA and a bid cap is manual CPC, whatever the campaign is
     * for.
     */
    public function bidStrategyFor(string $intent, ?string $objective = null): ?string
    {
        $wantsConversions = in_array((string) $objective, ['WEBSITE_CONVERSIONS', 'SALES', 'LEADS'], true);

        return match (strtoupper($intent)) {
            'AUTOMATIC' => $wantsConversions ? 'MAXIMIZE_CONVERSIONS' : 'MAXIMIZE_CLICKS',
            'TARGET_COST' => 'TARGET_CPA',
            'BID_CAP' => 'MANUAL_CPC',
            default => null,
        };
    }

    public function bidStrategies(): array
    {
        return ['MAXIMIZE_CONVERSIONS', 'TARGET_CPA', 'MAXIMIZE_CONVERSION_VALUE', 'TARGET_ROAS', 'MAXIMIZE_CLICKS', 'MANUAL_CPC'];
    }

    /** @return list<array{value: string, label: string, hint: string, sends: string}> */
    public function bidStrategyOptions(): array
    {
        return [
            [
                'value' => 'MAXIMIZE_CONVERSIONS',
                'label' => 'Maximise conversions',
                'hint' => 'Spend the budget for as many conversions as possible, at whatever they cost.',
                'sends' => 'Bid to maximise conversions',
            ],
            [
                'value' => 'TARGET_CPA',
                'label' => 'Target cost per action',
                'hint' => 'Hold the average cost per conversion near a number you name.',
                'sends' => 'Use a target cost per action',
            ],
            [
                'value' => 'MAXIMIZE_CONVERSION_VALUE',
                'label' => 'Maximise conversion value',
                'hint' => 'Chase revenue rather than conversion count. Needs values on your conversion action.',
                'sends' => 'Bid to maximise conversion value',
            ],
            [
                'value' => 'TARGET_ROAS',
                'label' => 'Target return on ad spend',
                'hint' => 'Aim for a return you name, as a multiple of spend. Needs conversion values.',
                'sends' => 'Use a target return on ad spend',
            ],
            [
                'value' => 'MAXIMIZE_CLICKS',
                'label' => 'Maximise clicks',
                'hint' => 'Buy as many clicks as the budget allows. Sensible before conversions are tracked.',
                'sends' => 'Bid to maximise clicks',
            ],
            [
                'value' => 'MANUAL_CPC',
                'label' => 'Manual CPC',
                'hint' => 'Set the bid yourself. Rarely the right answer once Smart Bidding has data.',
                'sends' => 'Use manual CPC',
            ],
        ];
    }

    /**
     * The two "target" strategies are defined by their number and are rejected
     * without one. The maximise strategies take the budget as their only input,
     * and manual CPC carries its bid on the ad group rather than the campaign.
     */
    public function bidStrategyNeedsTarget(string $strategy): bool
    {
        return in_array($strategy, ['TARGET_CPA', 'TARGET_ROAS'], true);
    }

    /**
     * Google calls these networks rather than placements.
     *
     * The same idea as Meta's: where the ad is allowed to appear, named on the
     * campaign. Stored in the same column because it answers the same question,
     * and translated to Google's network flags by the adapter.
     *
     * @return list<string>
     */
    public function placements(): array
    {
        return ['search', 'search_partners', 'display', 'youtube'];
    }

    /**
     * Search ads cannot run on YouTube, and an awareness goal is not bought on
     * the search network, where there is no impression to buy against intent.
     *
     * @return list<string>
     */
    public function placementsFor(string $objective): array
    {
        return match ($objective) {
            'BRAND_AWARENESS_AND_REACH' => ['display', 'youtube'],
            'WEBSITE_TRAFFIC' => ['search', 'search_partners', 'display'],
            default => $this->placements(),
        };
    }

    /**
     * Responsive search ads have no call to action field.
     *
     * Google writes the button itself from the final URL and the copy. Returning
     * an empty list is how the publish gate knows not to ask, rather than
     * blocking on a field that cannot be filled.
     *
     * @return list<string>
     */
    public function callsToAction(): array
    {
        return [];
    }

    /**
     * Not modelled for Google.
     *
     * Google does restrict employment, housing and credit advertising, but it
     * enforces it through account level policy and verification rather than a
     * field on the campaign. Declaring categories we cannot send would be a
     * question whose answer goes nowhere.
     *
     * @return list<string>
     */
    public function specialAdCategories(): array
    {
        return [];
    }

    /**
     * Google enforces no daily minimum, so neither does this.
     *
     * It will take any budget above zero and deliver proportionally less, and
     * it may spend up to twice the daily amount on a given day while holding
     * the month to 30.4 times it.
     *
     * This returned 5.00 until 21 Sep, which was Meta's floor applied to Google
     * because Meta was once the only platform. It was not merely conservative,
     * it was stated as Google's: the split tool said "Google Ads needs at least
     * 5.00 a day" and the publish gate said "below Google Ads's 5.00 minimum",
     * both of which are claims about Google that Google does not make. A buyer
     * cannot argue with a platform rule, which is what made inventing one worse
     * than having none.
     *
     * Nothing takes a zero budget: the publish gate refuses a campaign with no
     * budget before it ever reaches a floor, and SplitBudget refuses a share
     * that is not positive, both independently of this.
     */
    /**
     * Google's ValueTrack parameters, in single braces.
     *
     * Google substitutes these at click time the way Meta substitutes its own,
     * and understands none of Meta's: {{campaign.name}} would arrive at the
     * landing page as those literal characters. The ids rather than the names
     * because that is what ValueTrack gives, and {network} is the closest thing
     * Google has to a placement, distinguishing Search from a search partner or
     * the Display network.
     */
    public function trackingOptions(): array
    {
        $standard = 'utm_source=google&utm_medium=cpc&utm_campaign={campaignid}&utm_content={creative}';

        return [
            [
                'value' => $standard,
                'label' => 'Standard tagging',
                'hint' => 'Source, medium, campaign and ad. Google fills in the ids.',
                'badge' => 'Suggested',
                'sends' => 'Use the standard UTM tagging',
            ],
            [
                'value' => $standard.'&utm_term={keyword}',
                'label' => 'Standard, plus keyword',
                'hint' => 'Also records which keyword the click was matched against.',
                'sends' => 'Use the standard UTM tagging plus keyword',
            ],
            [
                'value' => $standard.'&utm_term={network}',
                'label' => 'Standard, plus network',
                'hint' => 'Also records whether the click came from Search, a search partner or Display.',
                'sends' => 'Use the standard UTM tagging plus network',
            ],
        ];
    }

    /**
     * The one configured account, so there is no account step.
     *
     * Google was rebuilt to run on a single internal customer id rather than a
     * per-user OAuth connection. The step outlived that: it pointed at
     * list_ad_accounts, which serves Meta, and childAccountOptions() reads
     * available_customer_ids, which only the OAuth flow ever filled in. So the
     * flow asked which account with nothing on screen, and stopped there.
     */
    public function defaultAdAccountId(): ?string
    {
        $customerId = str_replace('-', '', (string) config('services.google_ads.customer_id'));

        return $customerId === '' ? null : $customerId;
    }

    public function minimumDailyBudget(): float
    {
        return 0.00;
    }

    /**
     * One, because Google has one.
     *
     * A Google budget is a campaignBudget resource with an amountMicros, held
     * by the campaign and shared by its ad groups. There is no ad-group budget
     * to choose instead, no lifetime amount, and no Advantage campaign budget,
     * which is a Meta feature that was being offered here by name. Of the four
     * options a Google buyer used to see, three could not be published, and
     * GoogleAdsService sends the daily amount whichever was picked.
     *
     * Returning one entry means the question is never asked.
     */
    public function budgetModes(): array
    {
        return [
            [
                'value' => 'campaign:daily',
                'label' => 'Daily',
                'hint' => 'Google spends this much a day across the campaign, and may go over on a '
                    .'busy day and under on a quiet one.',
                'sends' => 'Use a daily campaign budget',
            ],
        ];
    }

    /**
     * Responsive search ad limits, which Google enforces on create.
     *
     * Unlike Meta, Google refuses the ad rather than truncating it, so these are
     * the difference between a campaign that exists and an API error. Primary
     * text is our field for the body copy and maps to a description.
     *
     * @return array<string, int>
     */
    public function copyLimits(): array
    {
        return [
            'headline' => 30,
            'description' => 90,
            'primary_text' => 90,
        ];
    }

    /**
     * Google's equivalent of a pixel is a conversion action, and a campaign that
     * bids toward conversions cannot be measured without one.
     */
    public function requiresPixel(string $objective): bool
    {
        return in_array($objective, ['SALES', 'LEADS'], true);
    }

    /** Google ads run from the account, not from a page that has to be chosen. */
    public function requiresPage(): bool
    {
        return false;
    }

    /**
     * A responsive search ad is text: headlines, descriptions and a URL.
     * Display and Performance Max are pictures.
     *
     * So on Google this is a property of the campaign type rather than of the
     * platform, which is why the contract takes a campaign. With no campaign to
     * ask about, the answer is the Search one, because that is what an
     * unanswered type falls back to.
     */
    public function requiresCreativeAsset(?Campaign $campaign = null): bool
    {
        return in_array($campaign?->campaign_type, ['display', 'performance_max'], true);
    }

    public function optimizationGoal(string $objective): ?string
    {
        return match ($objective) {
            'SALES', 'LEADS' => 'CONVERSIONS',
            'WEBSITE_TRAFFIC' => 'CLICKS',
            'BRAND_AWARENESS_AND_REACH' => 'IMPRESSIONS',
            default => null,
        };
    }

    /**
     * Google counts a named conversion action rather than a fixed event list, so
     * the event is the account's own and cannot be derived from the objective.
     */
    public function conversionEvent(string $objective): ?string
    {
        return null;
    }

    /**
     * What Google needs before a campaign can be built, beyond the shared checks.
     *
     * The "not connected yet" line this used to open with is gone: there is a
     * GooglePublisher now, and leaving a permanent blocker in place would make
     * it unreachable. What replaces it is the set of things that are genuinely
     * required to build a Search campaign and that the shared flow cannot know
     * to ask for.
     *
     * @return list<string>
     */
    public function additionalProblems(Campaign $campaign): array
    {
        $problems = [];

        // Meta's macros in a Google campaign's tracking.
        //
        // Google substitutes {campaignid} and {creative}; Meta's double-brace
        // macros mean nothing to it and arrive at the landing page as those
        // literal characters, so every click is misattributed and the URL is
        // visibly broken. Checked here because the campaigns that already carry
        // it were built before the tagging options were made per-platform, and
        // the gate passed them: a wrong value looks exactly like a right one.
        if (str_contains((string) $campaign->utm, '{{')) {
            $problems[] = 'the click tracking uses Meta macros, which Google does not replace: '
                .'re-answer the tracking question';
        }

        // Google has to be configured. Without this the failure is an exception
        // thrown from inside the publisher, after the campaign has been marked
        // as publishing.
        //
        // Asked of config rather than of the campaign's owner: this is one
        // common internal account, so it is either set up for everyone or for
        // nobody, and a per-user check reported it missing for colleagues who
        // simply had not run an OAuth flow they should never have needed.
        if (GoogleAdsConnection::shared() === null) {
            $problems[] = GoogleAdsConnection::missingReason();
        }

        // Google enforces these on create and refuses the ad outright, unlike
        // Meta, which accepts over-long copy and truncates it. A responsive
        // search ad needs three headlines and two descriptions at minimum.
        $ads = $campaign->ads()->get();
        $headlines = $ads->pluck('headline')->filter()->unique();
        $descriptions = $ads->flatMap(fn ($ad): array => [$ad->description, $ad->primary_text])->filter()->unique();

        if ($ads->isNotEmpty() && $headlines->count() < 3) {
            $problems[] = sprintf(
                'a responsive search ad needs at least 3 different headlines, and there are %d',
                $headlines->count(),
            );
        }

        if ($ads->isNotEmpty() && $descriptions->count() < 2) {
            $problems[] = sprintf(
                'a responsive search ad needs at least 2 different descriptions, and there are %d',
                $descriptions->count(),
            );
        }

        // Search buys against keywords, and the shared flow never asks for any
        // because Meta has no equivalent. They come from the landing page, so
        // the failure mode is a page that yielded none rather than a question
        // nobody answered.
        if (blank($campaign->brief?->pageAnalysis()?->keywords ?? [])) {
            $problems[] = 'no keywords could be read from the landing page, and a Search campaign needs them';
        }

        // Display and Performance Max run image ads, so they need pictures at
        // the two sizes Google states, and it rejects the ad group outright
        // without them. Checked here rather than left to the API, because by
        // the time the API says no the campaign and its budget already exist.
        if (in_array($campaign->campaign_type, ['display', 'performance_max'], true)) {
            foreach ([[1200, 628], [1200, 1200]] as [$width, $height]) {
                if (! $this->hasImageSized($campaign, $width, $height)) {
                    $problems[] = sprintf(
                        'a %s campaign needs a creative at %dx%d, and none of the attached ones is that size',
                        $this->describeCampaignType($campaign->campaign_type)['label'],
                        $width,
                        $height,
                    );
                }
            }
        }

        // Google campaign budgets are a daily amount. A lifetime total has no
        // direct equivalent, and dividing it silently would spend on a schedule
        // nobody agreed to.
        if ($campaign->usesLifetimeBudget()) {
            $problems[] = 'Google Ads takes a daily budget here, so a lifetime total cannot be published yet';
        }

        $unknown = array_diff((array) ($campaign->placements ?? []), $this->placements());

        if ($unknown !== []) {
            $problems[] = 'network not supported on Google Ads: '.implode(', ', $unknown);
        }

        // Search and Search partners are the same network as far as targeting
        // goes, and partners cannot be bought on their own.
        $chosen = (array) ($campaign->placements ?? []);

        if (in_array('search_partners', $chosen, true) && ! in_array('search', $chosen, true)) {
            $problems[] = 'search partners cannot run without the search network';
        }

        return $problems;
    }

    /**
     * Whether an attached creative is exactly this size.
     *
     * Read from the bytes rather than from the creative's recorded ratio: the
     * ratio is null on most rows, and "1:1" does not say whether the image is
     * 1200x1200 or 400x400, which is the difference Google enforces.
     */
    private function hasImageSized(Campaign $campaign, int $width, int $height): bool
    {
        foreach ($campaign->ads()->with('asset')->get() as $ad) {
            $asset = $ad->asset;

            if ($asset === null || $asset->kind !== 'image') {
                continue;
            }

            $disk = Storage::disk($asset->disk ?: 'local');

            if (! $disk->exists($asset->path)) {
                continue;
            }

            $size = @getimagesizefromstring($disk->get($asset->path));

            if (is_array($size) && $size[0] === $width && $size[1] === $height) {
                return true;
            }
        }

        return false;
    }

    /**
     * What Google needs that no other platform does.
     *
     * The landing page, the money, the dates, the creatives and the copy are
     * absent on purpose: those are the brief's, asked once however many
     * platforms the campaign runs on. That is what makes adding Google to an
     * existing Meta campaign five questions rather than fifteen.
     *
     * @return list<array{key: string, label: string, tool: string, offers: bool, ask?: string}>
     */
    public function steps(): array
    {
        return [
            // No ad_account step: see defaultAdAccountId(). There is one
            // account, it is set when the campaign is created, and asking which
            // one produced a question with no answers on screen.
            ['key' => 'campaign_type', 'label' => 'campaign type', 'tool' => 'choose_campaign_type', 'offers' => true,
                'ask' => 'What kind of Google campaign should this be?'],
            ['key' => 'objective', 'label' => 'campaign goal', 'tool' => 'choose_objective', 'offers' => true,
                'ask' => 'What should this campaign optimise for?'],
            ['key' => 'pixel', 'label' => 'conversion action', 'tool' => 'choose_pixel', 'offers' => true,
                'ask' => 'Which conversion action should this campaign count?'],
            ['key' => 'bidding', 'label' => 'bid strategy', 'tool' => 'set_bidding', 'offers' => true,
                'ask' => 'How should it bid?'],
            ['key' => 'targeting', 'label' => 'countries and networks', 'tool' => 'set_targeting', 'offers' => true,
                'ask' => 'Where should this run, and on which networks?'],
            // No utm step. Click tracking is set from the platform's own
            // macros when the campaign is created and the buyer is told,
            // rather than asked to choose the value that was going to be
            // used anyway. set_tracking still changes it on request.
        ];
    }

    /**
     * Google updates a campaign in place far more readily than Meta does, but an
     * ad's copy is still replaced rather than edited, so it is absent here.
     *
     * @return list<string>
     */
    /**
     * What GooglePublisher::update() can actually send.
     *
     * "locations" was claimed here and is not in the list any more. Nothing
     * pushed it, and a field declared editable but never sent is the worst
     * shape this can take: the edit is accepted, the snapshot moves, and the
     * campaign and the platform disagree with nothing saying so. Geo targets
     * are campaign criteria rather than campaign fields, so changing them means
     * diffing and mutating criteria, which is not built yet. Until it is, a
     * country change is refused as needing a rebuild rather than silently lost.
     *
     * @return list<string>
     */
    public function isConfigured(): bool
    {
        // One shared internal account, which exists only when both the refresh
        // token and the customer id are set.
        return GoogleAdsConnection::shared() !== null;
    }

    public function editableAfterPublish(): array
    {
        return ['name', 'budget', 'bid_strategy', 'bid_amount', 'starts_at', 'ends_at', 'placements'];
    }

    public function supportedAssetRatios(): array
    {
        return ['1.91:1', '1:1', '4:5'];
    }
}
