<?php

namespace App\Campaigns\Publishing;

use App\Campaigns\Platforms\GoogleRules;
use App\Campaigns\Targeting\CountryCatalog;
use App\Campaigns\Targeting\TargetingSelections;
use App\Campaigns\TrackedUrl;
use App\Models\Campaign;
use App\Models\CampaignAd;
use App\Models\GoogleAdsConnection;
use App\Services\GoogleAdsService;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use RuntimeException;

/**
 * Google's half of publishing: a paused campaign of the type the buyer chose.
 *
 * The work here is translation. GoogleAdsService already knows how to build a
 * paused Search campaign, and it has done since the Google chat shipped; what
 * it was never given was a campaign of ours to build it from. It read a
 * GoogleAdsDraft, filled in by a second agent in a second chat, so the campaign
 * a buyer assembled here, with its brief and its budget split and its publish
 * gate, was not the thing that reached Google.
 *
 * Search, Display and Performance Max. Search needs only words, so it is built
 * from the copy and the landing page alone. The other two run image ads, and
 * what they additionally need is gathered in displayExtras(): a business name
 * read from the page, a long headline taken from the longest copy that fits,
 * and the campaign's creatives uploaded to Google at the two sizes it demands.
 *
 * Video runs as part of Performance Max, from the YouTube id the campaign
 * holds. Shopping is the one type still absent, refused by GoogleRules rather
 * than here, because it needs a Merchant Center feed this product has no
 * concept of.
 */
class GooglePublisher implements PlatformPublisher
{
    public function __construct(
        private readonly GoogleAdsService $google,
        private readonly CountryCatalog $countries,
        private readonly TargetingSelections $selections,
    ) {}

    /**
     * @param  array<string,mixed>  $created
     * @return array<string,mixed>
     */
    public function create(Campaign $campaign, string $actor, array &$created): array
    {
        $connection = $this->connection($campaign);

        // $created is passed by reference and filled in as each object is made.
        // One call from here is several against Google, and a failure at the ad
        // group leaves a campaign and a budget already created. Reading the
        // return value alone would mean those ids only ever existed on success,
        // so every partial failure orphaned both on the account.
        $ids = $this->google->createPausedCampaign($connection, $this->payload($campaign), $created);

        foreach ($ids as $key => $value) {
            $created[$key] = $value;
        }

        return $created;
    }

    /**
     * Remove the campaign and the budget behind it.
     *
     * Only these two. Ad groups, keywords and ads live under the campaign and
     * go with it, which is the opposite of Meta, where a parent cannot be
     * deleted until its children are. The budget does not: it is an account
     * level object the campaign merely points at, so removing the campaign
     * alone leaves an orphan budget behind on every failed publish.
     *
     * @param  array<string,mixed>  $created
     */
    public function rollBack(Campaign $campaign, array $created, string $actor): void
    {
        $connection = GoogleAdsConnection::shared();

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

        $customerId = str_replace('-', '', (string) $connection->customer_id);

        if (filled($created['campaign_id'] ?? null)) {
            $this->google->removeResource(
                $connection,
                'customers/'.$customerId.'/campaigns/'.$created['campaign_id'],
                'campaigns',
            );
        }

        if (filled($created['budget_id'] ?? null)) {
            $this->google->removeResource(
                $connection,
                'customers/'.$customerId.'/campaignBudgets/'.$created['budget_id'],
                'campaignBudgets',
            );
        }
    }

    /**
     * The one account this product buys on.
     *
     * Not the campaign owner's. Google Ads is one common internal access like
     * Meta, so who is typing does not decide which account is reached; it used
     * to, and a user without their own OAuth row could not publish at all.
     */
    private function connection(Campaign $campaign): GoogleAdsConnection
    {
        return GoogleAdsConnection::shared()
            ?? throw new RuntimeException(GoogleAdsConnection::missingReason());
    }

    /**
     * Our campaign, in the shape GoogleAdsService builds from.
     *
     * @return array<string,mixed>
     */
    private function payload(Campaign $campaign): array
    {
        $ads = $campaign->ads()->orderBy('id')->get();

        return [
            // The type the buyer chose, not a constant. Hardcoding 'search'
            // here while the flow offered three types would have quietly built
            // a Search campaign for somebody who picked Display, which is the
            // worst kind of wrong: it succeeds.
            //
            // Search is the fallback only for a campaign that predates the
            // question, which is every Google campaign built before today.
            'campaign_type' => $campaign->campaign_type ?: 'search',
            'name' => $campaign->name,
            'ad_group_name' => $campaign->name.' - ad group',
            'daily_budget' => (float) $campaign->budget,
            'bidding_strategy' => (string) $campaign->bid_strategy,
            // Only the target strategies read this, and the gate has already
            // refused to publish one without it.
            'bid_target' => $campaign->bid_amount !== null ? (float) $campaign->bid_amount : null,
            // The landing page plus whatever tracking was agreed, built the same
            // way Meta's is so a click is tagged identically on both.
            'final_url' => TrackedUrl::for($campaign),
            'locations' => $this->locations($campaign),
            // Collected by set_targeting since the flow existed and never sent.
            // The buyer picked "mobile only", the panel showed mobile only, and
            // the campaign went out reaching every device Google has.
            'devices' => array_values((array) ($campaign->audience['devices'] ?? [])),
            // Collected by the shared flow and, until now, thrown away on the
            // way to Google. The networks a buyer picked were replaced by a
            // hardcoded set, the dates were never sent so a campaign with an
            // end date ran until somebody noticed, and the conversion action
            // the gate refuses to publish without went nowhere at all.
            'networks' => (array) ($campaign->placements ?? []),
            'starts_at' => $campaign->starts_at?->toDateString(),
            'ends_at' => $campaign->ends_at?->toDateString(),
            'conversion_action' => $campaign->pixel_id,
            // Search only; the display and performance max create paths never
            // read this, and demanding it there threw before they could run.
            'keywords' => ($campaign->campaign_type ?? 'search') === 'search'
                ? $this->keywords($campaign)
                : '',
            // Capped at what a responsive search ad holds. Six descriptions,
            // which three ads produce as a matter of course, is refused whole
            // with "TOO_MANY" and no mention of which field.
            'headlines' => array_slice($this->copy($ads, 'headline'), 0, GoogleRules::MAX_HEADLINES),
            'descriptions' => array_slice($this->copy($ads, 'description', 'primary_text'), 0, GoogleRules::MAX_DESCRIPTIONS),
            ...$this->displayExtras($campaign, $ads),
        ];
    }

    /**
     * What Display needs that Search does not, and Search ignores.
     *
     * Display runs image ads, so it needs pictures at two exact sizes and the
     * name of the business they are advertising, neither of which a Search
     * campaign has any use for. Returned as an empty array for Search rather
     * than branching the whole payload, because everything else is identical.
     *
     * The images are uploaded to Google here, at publish time, rather than when
     * the creative was made. An asset only needs to exist on Google if a
     * campaign actually uses it, and uploading on creation would put every
     * creative anyone generates onto the ad account.
     *
     * @param  Collection<int, CampaignAd>  $ads
     * @return array<string,mixed>
     */
    private function displayExtras(Campaign $campaign, $ads): array
    {
        if (($campaign->campaign_type ?: 'search') === 'search') {
            return [];
        }

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

        return [
            'business_name' => $this->businessName($campaign),
            // Google allows 90 characters here against 30 for a headline, so
            // the body copy is the better source: a headline padded out to
            // length would just repeat itself.
            'long_headline' => $this->longHeadline($campaign, $ads),
            'long_headlines' => [$this->longHeadline($campaign, $ads)],
            'marketing_image_assets' => $this->uploadImages($campaign, $connection, 1200, 628),
            'square_marketing_image_assets' => $this->uploadImages($campaign, $connection, 1200, 1200),
            'youtube_video_assets' => $this->uploadVideo($campaign, $connection),
        ];
    }

    /**
     * The YouTube video, where one was named, as a Google asset.
     *
     * Optional: Performance Max runs without video, it simply reaches less
     * inventory, so a campaign without one is not blocked over it.
     *
     * Created here rather than stored, because a Google video asset belongs to
     * an account. We hold the YouTube id, which is the thing that is true
     * wherever the campaign runs, and turn it into an asset at publish time.
     *
     * Google Ads video assets must reference a YouTube video. There is no path
     * that uploads one of our rendered files, so this never pretends to: a
     * campaign with a local video and no YouTube id simply has no video asset.
     *
     * @return list<string>
     */
    private function uploadVideo(Campaign $campaign, GoogleAdsConnection $connection): array
    {
        if (blank($campaign->youtube_video_id)) {
            return [];
        }

        return [$this->google->createYouTubeVideoAsset(
            $connection,
            (string) $campaign->youtube_video_id,
            $campaign->name.' video',
        )];
    }

    /**
     * Who the ad says it is from.
     *
     * Taken from the landing page's title rather than asked for. The page is
     * the business, the title almost always names it, and the brief already
     * holds the analysis, so asking would be asking somebody to retype
     * something they already gave us.
     *
     * Capped at Google's 25 character limit, on a word boundary where one is
     * available, because Google refuses a longer one outright.
     */
    private function businessName(Campaign $campaign): string
    {
        $title = trim((string) ($campaign->brief?->pageAnalysis()?->title ?? $campaign->name));

        // Page titles are routinely "Brand | Tagline" or "Brand - Tagline", and
        // the part before the separator is the bit that names the business.
        $name = trim((string) preg_split('/\s+[|\-–—:]\s+/u', $title)[0]);

        if ($name === '') {
            $name = $campaign->name;
        }

        return mb_strlen($name) <= 25 ? $name : rtrim(mb_substr($name, 0, 25));
    }

    /**
     * The long headline Display shows when it has room for one.
     *
     * The longest piece of copy that fits, because this is the one field where
     * Google gives more space than Meta ever does and truncating into it would
     * waste it.
     *
     * @param  Collection<int, CampaignAd>  $ads
     */
    private function longHeadline(Campaign $campaign, $ads): string
    {
        $candidates = $ads
            ->flatMap(fn ($ad): array => [$ad->primary_text, $ad->description, $ad->headline])
            ->filter(fn (?string $text): bool => filled($text))
            ->map(fn (string $text): string => trim($text))
            ->filter(fn (string $text): bool => mb_strlen($text) <= 90)
            ->sortByDesc(fn (string $text): int => mb_strlen($text));

        return (string) ($candidates->first() ?? mb_substr((string) $campaign->name, 0, 90));
    }

    /**
     * Our creatives at one of Google's required sizes, as asset resource names.
     *
     * Only exact matches. Google states 1200x628 and 1200x1200, and it rejects
     * an image group that does not contain them, so sending a near miss trades
     * a clear refusal here for an opaque one from the API after the campaign
     * and budget already exist.
     *
     * @return list<string>
     */
    private function uploadImages(Campaign $campaign, GoogleAdsConnection $connection, int $width, int $height): array
    {
        $resources = [];

        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;
            }

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

            if (! is_array($size) || $size[0] !== $width || $size[1] !== $height) {
                continue;
            }

            $resources[] = $this->google->createImageAsset(
                $connection,
                $contents,
                $asset->original_name ?: $campaign->name.' '.$width.'x'.$height,
            );
        }

        return array_values(array_unique($resources));
    }

    /**
     * Countries as Google's own geo target resources.
     *
     * @return list<string>
     */
    private function locations(Campaign $campaign): array
    {
        $codes = $this->selections->countries($campaign);
        $targets = $this->selections->googleGeoTargets($campaign);

        $this->countries->resolve('google', $codes, $campaign->ad_account_id);
        $wholeCountries = $targets['countries'] === []
            ? []
            : $this->countries->resolve('google', $targets['countries'], $campaign->ad_account_id);

        return array_values(array_unique([...$wholeCountries, ...$targets['geo_targets']]));
    }

    /**
     * What to bid on, taken from the page rather than asked for again.
     *
     * A Search campaign cannot exist without keywords, and the shared flow never
     * asks for any, because Meta has no equivalent. The landing page analysis
     * already extracted them and the brief already holds it, so the answer is
     * one the buyer effectively gave when they pasted the URL.
     *
     * Returned as the comma separated string GoogleAdsService splits, which is
     * the shape the Google chat sent it and is not worth changing here.
     */
    private function keywords(Campaign $campaign): string
    {
        $keywords = $campaign->brief?->pageAnalysis()?->keywords ?? [];

        if ($keywords === []) {
            throw new RuntimeException(
                'A Google Search campaign needs keywords, and none could be read from the landing page.'
            );
        }

        return implode(', ', array_slice($keywords, 0, 20));
    }

    /**
     * Ad copy gathered across every ad, deduplicated.
     *
     * Meta runs one creative per ad; a Google responsive search ad is one ad
     * holding many headlines that Google combines itself. So the copy written
     * for several Meta ads becomes the variations inside a single Google one,
     * which is the same words used the way each platform uses them.
     *
     * @param  Collection<int, CampaignAd>  $ads
     * @return list<string>
     */
    private function copy($ads, string ...$fields): array
    {
        return $ads
            ->flatMap(fn ($ad): array => array_map(fn (string $field): ?string => $ad->{$field}, $fields))
            ->filter(fn (?string $text): bool => filled($text))
            ->map(fn (string $text): string => trim($text))
            ->unique()
            ->values()
            ->all();
    }

    /**
     * Send approved changes to the live Google campaign.
     *
     * Nothing did this before: CampaignUpdater sent every platform's edits to
     * Meta, so a Google campaign's changes went to the Graph API carrying a
     * Google customer id and Google object ids, and were then recorded as
     * applied because the snapshot moves on success.
     *
     * Only the fields GoogleRules declares editable can arrive here, and every
     * one of them is handled. A field it claimed but this ignored would leave
     * the campaign and the platform disagreeing while our snapshot said they
     * matched, which is the failure the interface warns about.
     *
     * @param  array<string, array{from: mixed, to: mixed}>  $changes
     */
    public function update(Campaign $campaign, array $changes, string $actor): void
    {
        $connection = GoogleAdsConnection::shared()
            ?? throw new RuntimeException(GoogleAdsConnection::missingReason());

        $ids = (array) ($campaign->external_ids ?? []);
        $campaignId = (string) ($ids['campaign_id'] ?? $campaign->external_campaign_id);

        if ($campaignId === '') {
            throw new RuntimeException('This campaign has no Google campaign id, so there is nothing to update.');
        }

        $fields = [];

        if (array_key_exists('name', $changes)) {
            $fields['name'] = (string) $changes['name']['to'];
        }

        // Read off the model rather than the diff: the diff holds normalised
        // comparison strings, and these need the real dates and the real
        // numbers. Bidding is sent whole because the strategy and its target
        // are one block to Google, and half of it is not a valid campaign.
        if (array_key_exists('starts_at', $changes)) {
            $fields['startDate'] = $campaign->starts_at?->format('Ymd');
        }

        if (array_key_exists('ends_at', $changes)) {
            $fields['endDate'] = $campaign->ends_at?->format('Ymd');
        }

        if (array_key_exists('bid_strategy', $changes) || array_key_exists('bid_amount', $changes)) {
            $fields = [
                ...$fields,
                ...$this->google->biddingFor(
                    (string) $campaign->bid_strategy,
                    $campaign->bid_amount !== null ? (float) $campaign->bid_amount : null,
                ),
            ];
        }

        // Networks are campaign fields, so they move with the rest. Sent whole
        // for the same reason as bidding: Google reads the block, and naming
        // one network in the mask would clear the others.
        if (array_key_exists('placements', $changes)) {
            $fields['networkSettings'] = $this->google->networksFor(
                array_values((array) ($campaign->placements ?? [])),
                ['targetGoogleSearch' => true, 'targetSearchNetwork' => false, 'targetContentNetwork' => false],
            );
        }

        // The money is its own resource. An adopted campaign without its budget
        // id must fail rather than report an update Google never received.
        if (array_key_exists('budget', $changes) && blank($ids['budget_id'] ?? null)) {
            throw new RuntimeException('The Google budget id is missing; refresh this campaign before editing its budget.');
        }

        if (array_key_exists('budget', $changes)
            && array_key_exists('budget_reference_count', $ids)
            && $ids['budget_reference_count'] !== 1) {
            throw new RuntimeException('This Google budget may be shared by multiple campaigns and cannot be edited here.');
        }

        if (array_key_exists('budget', $changes) && array_key_exists('budget_reference_count', $ids)) {
            $profile = $this->google->readCampaignProfile($connection, $campaignId, fresh: true);
            $expectedBudget = 'customers/'.str_replace('-', '', (string) $connection->customer_id).'/campaignBudgets/'.$ids['budget_id'];

            if ($profile['id'] !== $campaignId
                || $profile['budget_resource'] !== $expectedBudget
                || $profile['budget_reference_count'] !== 1) {
                throw new RuntimeException('Google budget ownership changed since import. Refresh this campaign before editing its budget.');
            }
        }

        $this->google->updateLiveCampaign($connection, $campaignId, array_filter(
            $fields,
            fn (mixed $value): bool => $value !== null,
        ));

        if (array_key_exists('budget', $changes)) {
            $this->google->updateLiveBudget($connection, (string) $ids['budget_id'], (float) $campaign->budget);
        }
    }

    /**
     * Start the campaign, its ad group and its ad.
     *
     * Google's word for live is ENABLED. The ad group and the ad carry their own
     * status and were both created PAUSED, so enabling the campaign alone produces
     * a campaign that is live and serves nothing.
     */
    public function activate(Campaign $campaign, string $actor): void
    {
        $this->google->setLiveStatus($this->connection($campaign), $this->ids($campaign), 'ENABLED');
    }

    /** And stop it at the campaign, which is where delivery is decided. */
    public function pause(Campaign $campaign, string $actor): void
    {
        $ids = $this->ids($campaign);

        $this->google->setLiveStatus(
            $this->connection($campaign),
            ['campaign_id' => $ids['campaign_id'] ?? null],
            'PAUSED',
        );
    }

    /**
     * The ids recorded when the campaign was built.
     *
     * external_campaign_id is the fallback for the campaign itself, because an
     * adopted campaign has one without ever having had an external_ids map.
     *
     * @return array<string, mixed>
     */
    private function ids(Campaign $campaign): array
    {
        $ids = (array) ($campaign->external_ids ?? []);
        $campaignId = (string) ($ids['campaign_id'] ?? $campaign->external_campaign_id);

        if ($campaignId === '') {
            throw new RuntimeException('This campaign has no Google campaign id, so there is nothing to start.');
        }

        return [...$ids, 'campaign_id' => $campaignId];
    }
}
