<?php

namespace App\Campaigns;

use App\Campaigns\Publishing\Publishers;
use App\Models\Campaign;
use App\Models\Decision;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;

/**
 * The only class that creates anything on a live ad account.
 *
 * The agent cannot reach it. A proposal is recorded first, a human approves,
 * and only then does anything reach the platform. Everything is created paused,
 * and a failure part-way through removes whatever was already made, so an
 * account is never left holding half a campaign.
 *
 * Everything here is true whatever the platform. The payload itself is not, so
 * that part lives behind {@see Publishing\PlatformPublisher} and a new platform
 * inherits the decision, the approval, the gate re-check, the double publish
 * guard and the rollback by existing rather than by remembering to.
 */
class Publisher
{
    public function __construct(
        private readonly PublishGate $gate,
        private readonly PendingChanges $pending,
        private readonly Publishers $publishers,
    ) {}

    /** Record the intent. Nothing happens on the platform until approve() runs. */
    public function propose(Campaign $campaign, string $actor): Decision
    {
        $blocking = $this->gate->check($campaign);

        return Decision::create([
            'origin' => 'agent',
            'action' => 'publish_campaign',
            'subject_type' => 'campaign',
            'subject_id' => (string) $campaign->id,
            'params' => $campaign->only([
                'name', 'platform', 'objective', 'budget', 'budget_level', 'budget_mode',
                'ad_account_id', 'page_id', 'pixel_id',
            ]),
            'evidence' => ['blocking' => $blocking],
            'state' => $blocking ? 'blocked' : 'proposed',
            'actor' => $actor,
            'reason' => $blocking ? 'not ready: '.implode('; ', $blocking) : null,
        ]);
    }

    /**
     * Carry out an approved proposal: campaign, ad set, then one creative and
     * one ad per copy variation.
     */
    public function approve(Decision $decision, string $actor): Decision
    {
        $campaign = Campaign::findOrFail((int) $decision->subject_id);

        // Refuse a second publish outright.
        //
        // Two routes reach this: the chat tool and the panel button. Publishing
        // from one leaves the other's page showing a stale, still-enabled
        // button, and a second run created a whole second campaign on the
        // account while external_ids kept only the last one, orphaning the
        // first. The guard belongs here rather than in either caller, because
        // neither can see what the other did.
        if ($campaign->external_campaign_id) {
            return tap($decision)->update([
                'state' => 'blocked',
                'reason' => 'already published as '.$campaign->external_campaign_id,
                'result' => $campaign->external_ids,
            ]);
        }

        // Re-check now, not just at proposal time. A campaign can change
        // between the two, and approving a stale summary must not publish a
        // different campaign from the one that was shown.
        if ($blocking = $this->gate->check($campaign)) {
            return tap($decision)->update([
                'state' => 'blocked',
                'reason' => 'no longer ready: '.implode('; ', $blocking),
            ]);
        }

        // Which platform's payload to build. A platform with no publisher is
        // refused by name rather than by exception: "not implemented for
        // taboola" is something a user can act on.
        //
        // This is the second lock, after the gate. The gate is a list of reasons
        // that a rules class can stop supplying, and by the time a campaign for
        // an unimplemented platform reached the API the damage would be a real
        // campaign on a real account.
        $platform = $this->publishers->for($campaign->platform);

        if ($platform === null) {
            return tap($decision)->update([
                'state' => 'blocked',
                'reason' => sprintf(
                    'publishing is not implemented for [%s] yet',
                    $campaign->platform,
                ),
            ]);
        }

        $campaign->update(['status' => 'publishing', 'last_error' => null]);

        $created = [];

        try {
            $created = $platform->create($campaign, $actor, $created);

            $campaign->update([
                'status' => 'published',
                'external_campaign_id' => $created['campaign_id'],
                'external_ids' => $created,
            ]);

            // Record what the platform now holds, so any later edit can be
            // compared against it rather than silently diverging.
            $campaign->refresh()->update([
                'published_state' => $this->pending->snapshot($campaign),
                'last_synced_at' => now(),
            ]);

            return tap($decision)->update([
                'state' => 'applied',
                'actor' => $actor,
                'result' => $created,
                'applied_at' => now(),
            ]);
        } catch (Throwable $e) {
            $platform->rollBack($campaign, $created, $actor);

            $campaign->update(['status' => 'failed', 'last_error' => $e->getMessage()]);
            $decision->update([
                'state' => 'blocked',
                'reason' => $e->getMessage(),
                'result' => ['rolled_back' => $created],
            ]);

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

            throw $e;
        }
    }

    /**
     * Propose starting a campaign that is already built.
     *
     * Its own decision, separate from publishing, because publishing and spending
     * are separate acts. Everything this product builds is created paused - Meta
     * and Google PAUSED at every level, LinkedIn DRAFT - and it stays that way
     * until a buyer asks for it in the chat and approves this.
     *
     * Approving a publish does not pre-authorise this. That was the choice worth
     * making deliberately: the alternative is that one click both creates a
     * campaign and starts it spending, and the parking would be decoration.
     */
    public function proposeActivation(Campaign $campaign, string $actor): Decision
    {
        $blocking = $this->activationProblems($campaign);

        return Decision::create([
            'origin' => 'agent',
            'action' => 'activate_campaign',
            'subject_type' => 'campaign',
            'subject_id' => (string) $campaign->id,
            'params' => $campaign->only(['name', 'platform', 'budget', 'budget_mode', 'ad_account_id']),
            'evidence' => ['blocking' => $blocking],
            'state' => $blocking ? 'blocked' : 'proposed',
            'actor' => $actor,
            'reason' => $blocking ? 'cannot be started: '.implode('; ', $blocking) : null,
        ]);
    }

    /**
     * Carry out an approved activation: the whole tree, then the local record.
     *
     * Re-checked here as well as at proposal, for the same reason publishing is: a
     * campaign can change between the two, and approving a minute-old summary must
     * not start something else.
     */
    public function activate(Decision $decision, string $actor): Decision
    {
        $campaign = Campaign::findOrFail((int) $decision->subject_id);

        if ($blocking = $this->activationProblems($campaign)) {
            return tap($decision)->update([
                'state' => 'blocked',
                'reason' => 'cannot be started: '.implode('; ', $blocking),
            ]);
        }

        $platform = $this->publishers->for($campaign->platform);

        if ($platform === null) {
            return tap($decision)->update([
                'state' => 'blocked',
                'reason' => sprintf('starting a campaign is not implemented for [%s] yet', $campaign->platform),
            ]);
        }

        try {
            $platform->activate($campaign, $actor);

            // Written after the platform agreed, never before. A campaign recorded
            // as running that is not is worse than the reverse: the reverse shows
            // up as a buyer asking why nothing is spending, and this shows up as
            // nobody asking anything.
            $campaign->update(['activated_at' => now()]);

            return tap($decision)->update([
                'state' => 'applied',
                'actor' => $actor,
                'applied_at' => now(),
                'result' => ['activated_at' => $campaign->fresh()->activated_at?->toIso8601String()],
            ]);
        } catch (Throwable $e) {
            // Deliberately not rolled back. Activation walks from the deepest
            // object up, so a failure leaves the campaign itself still paused and
            // nothing spending; trying to put the lower levels back would be a
            // second set of calls against a platform that has just failed one.
            $campaign->update(['last_error' => $e->getMessage()]);

            $decision->update(['state' => 'blocked', 'reason' => $e->getMessage()]);

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

            throw $e;
        }
    }

    /**
     * Stop a running campaign.
     *
     * Not gated behind an approval. Approvals exist to stand between a buyer and
     * spending their money, and this is the direction that stops it - asking
     * somebody to confirm twice before they can halt a campaign is a way to lose
     * money, not to protect it.
     */
    public function pause(Campaign $campaign, string $actor): void
    {
        if (! $campaign->isRunning()) {
            return;
        }

        $platform = $this->publishers->for($campaign->platform)
            ?? throw new RuntimeException(sprintf(
                'pausing is not implemented for [%s] yet',
                $campaign->platform,
            ));

        $platform->pause($campaign, $actor);

        $campaign->update(['activated_at' => null]);
    }

    /**
     * Why this campaign cannot be started, if it cannot.
     *
     * Public because the chat asks before proposing anything: a tool that creates a
     * decision row in order to find out whether it could have is leaving a trail of
     * blocked decisions behind every question.
     *
     * @return list<string>
     */
    public function activationProblems(Campaign $campaign): array
    {
        $problems = [];

        if (! $campaign->isPublished()) {
            $problems[] = 'it has not been published yet, so there is nothing on the platform to start';
        }

        if ($campaign->isRunning()) {
            $problems[] = 'it is already running';
        }

        // The same questions publishing asks, minus the one they disagree on:
        // check() refuses a campaign that is already on the platform, and this
        // requires it. A campaign can lose something between being published and
        // being started - a creative removed, an account no longer permitted - and
        // starting it then spends money against a campaign that would be refused a
        // publish today.
        return [...$problems, ...$this->gate->soundness($campaign)];
    }
}
