<?php

namespace App\Campaigns;

use App\Campaigns\Platforms\Platforms;
use App\Models\Campaign;

/**
 * The difference between what we hold and what the platform holds.
 *
 * Editing a live campaign used to update our copy and nothing else: the
 * platform kept the old values, the screen showed the new ones, and nothing
 * said they disagreed. Comparing against published_state, which is what was
 * last sent, is what makes that visible.
 *
 * Changes are split in two because platforms treat them differently. Budget and
 * schedule can be updated in place. An ad's creative cannot: Meta treats it as
 * immutable, so changing a headline means building a new creative and a new ad
 * and retiring the old one. Presenting both as "an edit" hides work that
 * behaves nothing alike.
 */
class PendingChanges
{
    /**
     * Where the column name is not what to say to a person.
     *
     * "utm cannot be changed after publishing" tells a buyer nothing about
     * what they would have to redo.
     *
     * @var array<string, string>
     */
    private const LABELS = [
        'utm' => 'click tracking is part of the destination URL, so changing it needs a new creative and a new ad',
        'budget_level' => 'a budget cannot be moved between the campaign and the ad set after publishing',
        'budget_mode' => 'a daily budget cannot become a lifetime one after publishing',
    ];

    public function __construct(private readonly Platforms $platforms) {}

    /**
     * Fields changed since publish that the platform can update in place.
     *
     * @return array<string, array{from: mixed, to: mixed}>
     */
    public function updatable(Campaign $campaign): array
    {
        if (! $campaign->isPublished()) {
            return [];
        }

        $published = (array) ($campaign->published_state ?? []);
        $editable = $this->platforms->for($campaign)->editableAfterPublish();
        $changes = [];

        foreach ($editable as $field) {
            $now = $this->normalise($campaign->getAttribute($field));
            $then = $this->normalise($published[$field] ?? null);

            if ($now !== $then) {
                $changes[$field] = ['from' => $then, 'to' => $now];
            }
        }

        return $changes;
    }

    /**
     * Changes that cannot be an update, and need the ad rebuilding instead.
     *
     * @return list<string>
     */
    public function requiringRebuild(Campaign $campaign): array
    {
        if (! $campaign->isPublished()) {
            return [];
        }

        $published = (array) ($campaign->published_state ?? []);
        $problems = [];

        if ($this->adsFingerprint($campaign) !== ($published['ads_fingerprint'] ?? null)) {
            $problems[] = 'ad copy or creative changed, which needs a new creative and a new ad';
        }

        // These are set at creation and cannot move afterwards on any platform
        // we support. Changing one means a new campaign, not an edit.
        // budget_level and budget_mode included: Meta will change the amount on
        // a running campaign but not move it between the campaign and the ad
        // set, nor turn a daily budget into a lifetime one.
        foreach (['objective', 'ad_account_id', 'page_id', 'pixel_id', 'landing_url', 'utm', 'budget_level', 'budget_mode'] as $field) {
            $now = $this->normalise($campaign->getAttribute($field));
            $then = $this->normalise($published[$field] ?? null);

            if ($then !== null && $now !== $then) {
                $problems[] = self::LABELS[$field] ?? str_replace('_', ' ', $field)
                    .' cannot be changed after publishing';
            }
        }

        if (array_key_exists('locations', $published) && $this->normalise($campaign->locations) !== $published['locations']) {
            $problems[] = 'location targeting changed, which needs a new campaign';
        }

        if (array_key_exists('targeting_locations', $published) && $this->targetingLocations($campaign) !== $published['targeting_locations']) {
            $problems[] = 'city or region targeting changed, which needs a new campaign';
        }

        return $problems;
    }

    public function any(Campaign $campaign): bool
    {
        return $this->updatable($campaign) !== [] || $this->requiringRebuild($campaign) !== [];
    }

    /**
     * What to store as the platform's current state once a publish or update
     * succeeds.
     *
     * @return array<string, mixed>
     */
    public function snapshot(Campaign $campaign): array
    {
        $fields = [
            ...$this->platforms->for($campaign)->editableAfterPublish(),
            'objective', 'ad_account_id', 'page_id', 'pixel_id', 'landing_url', 'utm',
            'budget_level', 'budget_mode',
        ];

        $state = [];

        foreach (array_unique($fields) as $field) {
            $state[$field] = $this->normalise($campaign->getAttribute($field));
        }

        $state['ads_fingerprint'] = $this->adsFingerprint($campaign);
        $state['locations'] = $this->normalise($campaign->locations);
        $state['targeting_locations'] = $this->targetingLocations($campaign);

        return $state;
    }

    /** @return list<array{kind: string, operation: string, country_code: string|null, external_id: string|null}> */
    private function targetingLocations(Campaign $campaign): array
    {
        return $campaign->targetingSet?->locations()
            ->where('kind', '!=', 'country')
            ->with('platformLocation:id,external_id')
            ->orderBy('id')
            ->get()
            ->map(fn ($selection): array => [
                'kind' => $selection->kind,
                'operation' => $selection->operation,
                'country_code' => $selection->country_code,
                'external_id' => $selection->platformLocation?->external_id,
            ])
            ->all() ?? [];
    }

    /**
     * A stable summary of every ad's words and image.
     *
     * Cheaper and clearer than diffing rows: any change to copy or creative
     * changes the fingerprint, and that is the only question being asked.
     */
    private function adsFingerprint(Campaign $campaign): string
    {
        return hash('sha256', $campaign->ads()->orderBy('id')->get()
            ->map(fn ($ad): string => implode('|', [
                $ad->headline, $ad->primary_text, (string) $ad->description, $ad->cta, (string) $ad->asset_id,
            ]))
            ->implode("\n"));
    }

    /** Dates and decimals compare badly across a JSON round trip. */
    private function normalise(mixed $value): mixed
    {
        return match (true) {
            $value instanceof \DateTimeInterface => $value->format('Y-m-d H:i:s'),
            is_numeric($value) => (string) $value,
            default => $value,
        };
    }
}
