<?php

namespace App\Campaigns;

use App\Models\Campaign;
use App\Models\CampaignChange;
use App\Services\Meta\Meta;
use App\Services\Platforms\Support\Guardrails;
use Illuminate\Support\Facades\Log;
use Throwable;

/**
 * Pushes changes to a campaign that is already live.
 *
 * The counterpart to Publisher, and deliberately as strict. Publishing and
 * editing both spend money, so an edit goes through the same shape: a change is
 * recorded first, a human approves, and only then does anything reach the
 * platform. Every attempt is written down whether it worked or not.
 *
 * What this will not do is disguise a rebuild as an update. Changing an ad's
 * copy or image is not an edit on any platform we support; it is a new creative
 * and a new ad. That is refused here rather than silently half-applied.
 */
class CampaignUpdater
{
    public function __construct(
        private readonly PendingChanges $pending,
        private readonly Guardrails $guardrails,
        private readonly Meta $meta,
    ) {}

    /**
     * Record the intent. Nothing reaches the platform until apply() runs.
     */
    public function propose(Campaign $campaign, string $actor): CampaignChange
    {
        return CampaignChange::create([
            'campaign_id' => $campaign->id,
            'actor' => $actor,
            'changes' => $this->pending->updatable($campaign),
            'state' => 'pending',
        ]);
    }

    /**
     * Send an approved change to the platform.
     *
     * Re-reads the difference now rather than trusting the proposal: a campaign
     * can move between the two, and applying a stale diff would push values
     * nobody approved.
     */
    public function apply(CampaignChange $change, string $actor): CampaignChange
    {
        $campaign = $change->campaign;

        if (! $campaign->isLive()) {
            return $this->reject($change, 'This campaign has not been published, so there is nothing to update.');
        }

        if ($blocked = $this->pending->requiringRebuild($campaign)) {
            return $this->reject($change, implode('; ', $blocked));
        }

        $changes = $this->pending->updatable($campaign);

        if ($changes === []) {
            return $this->reject($change, 'Nothing has changed since this campaign was published.');
        }

        try {
            $this->guardrails->assertChangeAllowed($campaign->id);

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

            // Only now does our record of the platform's state move.
            $campaign->update([
                'published_state' => $this->pending->snapshot($campaign),
                'last_synced_at' => now(),
                'last_error' => null,
            ]);

            return tap($change)->update([
                'changes' => $changes,
                'state' => 'applied',
                'actor' => $actor,
                'applied_at' => now(),
            ]);
        } catch (Throwable $e) {
            $campaign->update(['last_error' => $e->getMessage()]);

            Log::warning('Campaign update failed', ['campaign' => $campaign->id, 'error' => $e->getMessage()]);

            return $this->reject($change, $e->getMessage());
        }
    }

    /**
     * Group changed fields by the object that owns them.
     *
     * Name lives on the campaign; budget, bidding and schedule live on the ad
     * set. 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
     * @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'] ?? '');

        $grouped = [];

        foreach ($changes as $field => $change) {
            [$key, $name, $value] = match ($field) {
                'name' => [$campaignKey, 'name', $change['to']],
                'budget' => [$adSetKey, 'daily_budget', (int) round((float) $change['to'] * 100)],
                'bid_strategy' => [$adSetKey, 'bid_strategy', $change['to']],
                'bid_amount' => [$adSetKey, 'bid_amount', (int) round((float) $change['to'] * 100)],
                // 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 || str_ends_with($key, ':')) {
                continue;
            }

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

        return $grouped;
    }

    private function reject(CampaignChange $change, string $reason): CampaignChange
    {
        return tap($change)->update(['state' => 'failed', 'error' => $reason]);
    }
}
