<?php

namespace App\Campaigns;

use App\Campaigns\Publishing\Publishers;
use App\Models\Campaign;
use App\Models\CampaignChange;
use App\Services\Platforms\Support\Guardrails;
use Illuminate\Support\Facades\Log;
use RuntimeException;
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 Publishers $publishers,
    ) {}

    /**
     * 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->isPublished()) {
            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.');
        }

        if ($changes !== (array) $change->changes) {
            return $this->reject($change, 'Campaign settings changed since review. Review and approve the current changes again.');
        }

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

            // The platform's own, rather than Meta for everything. This called
            // the Graph API whatever the campaign was on, so a Google campaign's
            // edits went to Meta carrying Google ids, and were then recorded as
            // applied because the snapshot below moves on success.
            $publisher = $this->publishers->for($campaign->platform)
                ?? throw new RuntimeException(sprintf(
                    'editing is not implemented for [%s] yet',
                    $campaign->platform,
                ));

            $publisher->update($campaign, $changes, $actor);

            // 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());
        }
    }

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