<?php

namespace App\Campaigns\Publishing;

use App\Campaigns\Objectives;
use App\Campaigns\Targeting\CountryCatalog;
use App\Campaigns\Targeting\TargetingSelections;
use App\Campaigns\TrackedUrl;
use App\Models\Campaign;
use App\Models\CampaignAd;
use App\Services\Meta\AdSetSpec;
use App\Services\Meta\CampaignSpec;
use App\Services\Meta\CreativeSpec;
use App\Services\Meta\Meta;
use App\Support\CurrencyMinorUnits;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;

/**
 * Meta's half of publishing: campaign, ad set, then a creative and an ad per
 * copy variation.
 *
 * Moved out of Publisher unchanged when Google needed the same lifecycle around
 * a different payload. Publisher had no platform dispatch at all, so it built
 * Meta specs for whatever it was handed and the only thing stopping a Google
 * campaign reaching Meta's API was a line of text in a rules class.
 */
class MetaPublisher implements PlatformPublisher
{
    public function __construct(
        private readonly Meta $meta,
        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
    {
        $this->countries->resolve('meta', $this->selections->countries($campaign), $campaign->ad_account_id);
        $this->selections->metaGeoLocations($campaign);
        $meta = $this->meta->on($campaign->id, $actor);

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

        // Ask the platform to check the payload before anything exists. Cheap,
        // and it turns a rollback into a clean refusal whenever Meta changes
        // what it requires.
        $meta->validateCampaign($spec);

        $created['campaign_id'] = $meta->createCampaign($spec);
        $created['adset_id'] = $meta->createAdSet($this->adSet($campaign, $created['campaign_id']));
        $created['ads'] = $this->ads($campaign, $meta, $created['adset_id'], $created);

        return $created;
    }

    /**
     * Deepest first: Meta refuses to delete a campaign that still has children.
     *
     * @param  array<string,mixed>  $created
     */
    public function rollBack(Campaign $campaign, array $created, string $actor): void
    {
        foreach (array_reverse($created['ads'] ?? []) as $ad) {
            $this->quietlyDelete($ad['ad_id'] ?? null);
            $this->quietlyDelete($ad['creative_id'] ?? null);

            if ($row = CampaignAd::find($ad['ad_row'] ?? null)) {
                $row->update(['external_creative_id' => null, 'external_ad_id' => null, 'status' => 'failed']);
            }
        }

        $this->quietlyDelete($created['adset_id'] ?? null);
        $this->quietlyDelete($created['campaign_id'] ?? null);
    }

    /**
     * The campaign itself, which now carries the budget whenever the advertiser
     * chose Advantage campaign budget rather than a per-ad-set one.
     */
    private function campaign(Campaign $campaign): CampaignSpec
    {
        return new CampaignSpec(
            adAccountId: $campaign->ad_account_id,
            name: $campaign->name,
            objective: $campaign->objective,
            // Declared by the advertiser, not defaulted. The gate refuses to
            // publish until this has actually been answered.
            specialAdCategories: (array) ($campaign->special_ad_categories ?? []),
            budgetLevel: $campaign->budgetLevel(),
            budgetMode: $campaign->budgetMode(),
            budgetMinorUnits: $campaign->budgetInMinorUnits(),
            bidStrategy: $campaign->bid_strategy ?? 'LOWEST_COST_WITHOUT_CAP',
            endsAt: $campaign->ends_at,
        );
    }

    private function adSet(Campaign $campaign, string $campaignId): AdSetSpec
    {
        return new AdSetSpec(
            adAccountId: $campaign->ad_account_id,
            campaignId: $campaignId,
            name: $campaign->name.' - ad set',
            budgetMinorUnits: $campaign->budgetInMinorUnits(),
            optimizationGoal: $campaign->optimization_goal ?? Objectives::optimizationGoal($campaign->objective),
            targeting: $this->targeting($campaign),
            pageId: $campaign->page_id,
            pixelId: $campaign->pixel_id,
            // What was chosen, or the objective's default when nobody chose.
            //
            // The default alone is what put a LEAD optimisation on a pixel that
            // only fires PageView and Subscribe.
            conversionEvent: $campaign->conversion_event ?: Objectives::conversionEvent($campaign->objective),
            bidStrategy: $campaign->bid_strategy ?? 'LOWEST_COST_WITHOUT_CAP',
            bidAmountCents: $campaign->bidInMinorUnits(),
            startsAt: $campaign->starts_at,
            endsAt: $campaign->ends_at,
            budgetLevel: $campaign->budgetLevel(),
            budgetMode: $campaign->budgetMode(),
            currency: $campaign->currency,
        );
    }

    /** @return array<string,mixed> */
    private function targeting(Campaign $campaign): array
    {
        return [
            ...(array) ($campaign->locations ?? []),
            ...(array) ($campaign->audience ?? []),
            'geo_locations' => $this->selections->metaGeoLocations($campaign),
            'placements' => (array) ($campaign->placements ?? []),
        ];
    }

    /**
     * One creative and one ad per copy row.
     *
     * @param  array<string,mixed>  $created  updated as we go, so a failure
     *                                        halfway still knows what to undo
     * @return list<array{ad_row: int, creative_id: string, ad_id: string}>
     */
    private function ads(Campaign $campaign, Meta $meta, string $adSetId, array &$created): array
    {
        $ads = [];

        // The landing page plus whatever tracking was agreed. Built once here
        // rather than per ad, so every ad in a campaign is tagged the same.
        $destination = TrackedUrl::for($campaign);

        foreach ($campaign->ads()->orderBy('id')->get() as $index => $ad) {
            $creativeId = $meta->createCreative($this->creative($campaign, $ad, $meta, $destination, $index));

            $adId = $meta->createAd(
                $campaign->ad_account_id,
                $adSetId,
                $creativeId,
                $campaign->name.' - ad '.($index + 1),
            );

            $ad->update([
                'external_creative_id' => $creativeId,
                'external_ad_id' => $adId,
                'status' => 'published',
            ]);

            $created['ads'] = $ads = [...$ads, [
                'ad_row' => $ad->id,
                'creative_id' => $creativeId,
                'ad_id' => $adId,
            ]];
        }

        return $ads;
    }

    /**
     * The creative for one ad, built from whichever kind of asset it carries.
     *
     * An image and a video are not two spellings of the same payload. The
     * reference Meta gave us is a hash for one and an id for the other, they
     * live in different columns, and this read the hash whatever the asset was:
     * a video produced a null hash and a TypeError, thrown after the campaign
     * and ad set already existed on the account.
     *
     * A video also has to be ready before an ad can name it, and needs a still,
     * both of which Meta only has once transcoding finishes.
     */
    private function creative(Campaign $campaign, CampaignAd $ad, Meta $meta, string $destination, int $index): CreativeSpec
    {
        $reference = $ad->asset->refFor($campaign->platform, $campaign->ad_account_id);

        $shared = [
            'adAccountId' => $campaign->ad_account_id,
            'pageId' => $campaign->page_id,
            'landingUrl' => $destination,
            'headline' => $ad->headline,
            'primaryText' => $ad->primary_text,
            'callToAction' => $ad->cta,
            'name' => $campaign->name.' - creative '.($index + 1),
            'description' => (string) $ad->description,
            'instagramUserId' => $campaign->instagram_account_id,
        ];

        if ($ad->asset->kind !== 'video') {
            return new CreativeSpec(...$shared, imageHash: $reference->external_hash);
        }

        $videoId = $reference->external_id;

        $meta->awaitVideoReady($videoId);

        return new CreativeSpec(
            ...$shared,
            videoId: $videoId,
            thumbnailUrl: $meta->videoThumbnail($videoId),
        );
    }

    private function quietlyDelete(?string $objectId): void
    {
        if (! $objectId) {
            return;
        }

        try {
            $this->meta->delete($objectId);
        } catch (Throwable $e) {
            // A failed rollback must not mask the error that caused it.
            Log::warning('Campaign rollback failed', ['object' => $objectId, 'error' => $e->getMessage()]);
        }
    }

    /**
     * Send approved changes to the live campaign.
     *
     * Lifted from CampaignUpdater, which did this for every platform against
     * Meta. The grouping is Meta's and belongs here: name lives on the
     * campaign; budget, bidding and schedule live on the ad set, unless the
     * campaign holds the budget itself, in which case the money and the bid
     * strategy move up with it. Sending a budget to a campaign id is accepted
     * and quietly ignored, which is worse than an error.
     *
     * @param  array<string, array{from: mixed, to: mixed}>  $changes
     */
    public function update(Campaign $campaign, array $changes, string $actor): void
    {
        $meta = $this->meta->on($campaign->id, $actor);

        foreach ($this->byEntity($campaign, $changes) as $entity => $fields) {
            [$type, $id] = explode(':', $entity);

            $meta->updateEntity($campaign->ad_account_id, $id, $type, $fields);
        }
    }

    /**
     * Start every level, because Meta needs all three.
     *
     * A campaign set ACTIVE over a PAUSED ad set delivers nothing. Meta reports
     * that as a campaign with no impressions, which is what an ordinary
     * slow-starting campaign also looks like - so the failure is invisible for as
     * long as anyone is willing to wait for it.
     *
     * Deepest first. If this stops half way the campaign above is still paused and
     * nothing is spending, which is the safe end to fail at; the other order leaves
     * a live campaign over paused ads and a buyer who has been told it started.
     */
    public function activate(Campaign $campaign, string $actor): void
    {
        $this->setStatus($campaign, $actor, 'ACTIVE');
    }

    /**
     * Stop at the campaign, which is enough to halt delivery.
     *
     * The ad set and the ads are left as they are on purpose: they were set live
     * by activate(), and putting them back to PAUSED here would mean activate()
     * had to know which of them the buyer had since changed by hand on Meta.
     */
    public function pause(Campaign $campaign, string $actor): void
    {
        $meta = $this->meta->on($campaign->id, $actor);

        $meta->updateEntity(
            $campaign->ad_account_id,
            (string) $campaign->external_campaign_id,
            'campaign',
            ['status' => 'PAUSED'],
        );
    }

    private function setStatus(Campaign $campaign, string $actor, string $status): void
    {
        $meta = $this->meta->on($campaign->id, $actor);
        $ids = (array) ($campaign->external_ids ?? []);

        foreach ($campaign->ads()->whereNotNull('external_ad_id')->pluck('external_ad_id') as $adId) {
            $meta->updateEntity($campaign->ad_account_id, (string) $adId, 'ad', ['status' => $status]);
        }

        // Named ad_set here and adset_id in our own column; sending our spelling
        // is rejected as "Invalid entity_type: adset", and only when a real
        // update is attempted.
        if (filled($adSetId = $ids['adset_id'] ?? null)) {
            $meta->updateEntity($campaign->ad_account_id, (string) $adSetId, 'ad_set', ['status' => $status]);
        }

        $meta->updateEntity(
            $campaign->ad_account_id,
            (string) $campaign->external_campaign_id,
            'campaign',
            ['status' => $status],
        );
    }

    /**
     * Group changed fields by the Meta object that owns them.
     *
     * @param  array<string, array{from: mixed, to: mixed}>  $changes
     * @return array<string, array<string, mixed>>
     */
    private function byEntity(Campaign $campaign, array $changes): array
    {
        $ids = (array) ($campaign->external_ids ?? []);
        $campaignKey = 'campaign:'.$campaign->external_campaign_id;
        // Meta names the entity type ad_set, not adset. Our own external_ids
        // key is adset_id, and using it here was rejected as
        // "Invalid entity_type: adset" only once a real update was attempted.
        $adSetKey = 'ad_set:'.($ids['adset_id'] ?? '');

        // Whichever object is holding the money is the one to send it to.
        $budgetKey = $campaign->usesCampaignBudget() ? $campaignKey : $adSetKey;
        $budgetField = $campaign->usesLifetimeBudget() ? 'lifetime_budget' : 'daily_budget';

        $grouped = [];
        $unaddressable = [];

        foreach ($changes as $field => $change) {
            [$key, $name, $value] = match ($field) {
                'name' => [$campaignKey, 'name', $change['to']],
                'budget' => [$budgetKey, $budgetField, CurrencyMinorUnits::toMinor($change['to'], $campaign->currency)],
                'bid_strategy' => [$budgetKey, 'bid_strategy', $change['to']],
                'bid_amount' => [$adSetKey, 'bid_amount', CurrencyMinorUnits::toMinor($change['to'], $campaign->currency)],
                // Read off the model, not the diff: the diff holds a comparison
                // string with no timezone, and Meta reads an unqualified string
                // in the ad account's own timezone rather than ours.
                'starts_at' => [$adSetKey, 'start_time', $campaign->starts_at?->format(\DateTimeInterface::ATOM)],
                'ends_at' => [$adSetKey, 'end_time', $campaign->ends_at?->format(\DateTimeInterface::ATOM)],
                default => [null, null, null],
            };

            if ($key === null) {
                continue;
            }

            // A change we cannot address is refused, not dropped.
            //
            // An adopted campaign whose ad set list came back empty has no
            // adset_id, so this key was "ad_set:" and the field was skipped in
            // silence. update() then returned cleanly, CampaignUpdater wrote
            // the new published_state and marked the change applied, and the
            // buyer was told their budget had changed while Meta was never
            // asked. starts_at, ends_at and bid_amount always route to the ad
            // set, so on such a campaign they were all lost the same way.
            if (str_ends_with($key, ':')) {
                $unaddressable[] = $field;

                continue;
            }

            $grouped[$key][$name] = $value;
        }

        if ($unaddressable !== []) {
            throw new RuntimeException(sprintf(
                'This campaign has no ad set recorded, so %s cannot be changed on the platform. '
                .'It was adopted without one; open it again to re-read its ad sets.',
                implode(' or ', $unaddressable),
            ));
        }

        return $grouped;
    }
}
