<?php

namespace App\Campaigns;

use App\Models\Campaign;
use App\Models\CampaignAd;
use App\Models\Decision;
use App\Services\Meta\AdSetSpec;
use App\Services\Meta\CreativeSpec;
use App\Services\Meta\Meta;
use Illuminate\Support\Facades\Log;
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.
 */
class Publisher
{
    public function __construct(
        private readonly Meta $meta,
        private readonly PublishGate $gate,
    ) {}

    /** 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', '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),
            ]);
        }

        $meta = $this->meta->on($campaign->id, $actor);
        $campaign->update(['status' => 'publishing', 'last_error' => null]);

        $created = [];

        try {
            // 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($campaign->ad_account_id, $campaign->name, $campaign->objective);

            $created['campaign_id'] = $meta->createCampaign(
                $campaign->ad_account_id,
                $campaign->name,
                $campaign->objective,
            );

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

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

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

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

    private function adSet(Campaign $campaign, string $campaignId): AdSetSpec
    {
        return new AdSetSpec(
            adAccountId: $campaign->ad_account_id,
            campaignId: $campaignId,
            name: $campaign->name.' - ad set',
            dailyBudgetCents: (int) round((float) $campaign->budget * 100),
            optimizationGoal: $campaign->optimization_goal ?? Objectives::optimizationGoal($campaign->objective),
            targeting: $this->targeting($campaign),
            pageId: $campaign->page_id,
            pixelId: $campaign->pixel_id,
            conversionEvent: Objectives::conversionEvent($campaign->objective),
            bidStrategy: $campaign->bid_strategy ?? 'LOWEST_COST_WITHOUT_CAP',
            bidAmountCents: $campaign->bid_amount ? (int) round((float) $campaign->bid_amount * 100) : null,
        );
    }

    /** @return array<string,mixed> */
    private function targeting(Campaign $campaign): array
    {
        return [
            ...(array) ($campaign->locations ?? []),
            ...(array) ($campaign->audience ?? []),
            '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 = [];

        foreach ($campaign->ads()->orderBy('id')->get() as $index => $ad) {
            $hash = $ad->asset->refFor($campaign->platform, $campaign->ad_account_id)->external_hash;

            $creativeId = $meta->createCreative(new CreativeSpec(
                adAccountId: $campaign->ad_account_id,
                pageId: $campaign->page_id,
                imageHash: $hash,
                landingUrl: $campaign->landing_url,
                headline: $ad->headline,
                primaryText: $ad->primary_text,
                callToAction: $ad->cta,
                name: $campaign->name.' - creative '.($index + 1),
                description: (string) $ad->description,
            ));

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

    /**
     * Remove whatever was created before the failure, deepest first: Meta
     * refuses to delete a campaign that still has children.
     */
    private function rollBack(array $created): 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);
    }

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