<?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
{
    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->isLive()) {
            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->isLive()) {
            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.
        foreach (['objective', 'ad_account_id', 'page_id', 'pixel_id', 'landing_url'] as $field) {
            $now = $this->normalise($campaign->getAttribute($field));
            $then = $this->normalise($published[$field] ?? null);

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

        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',
        ];

        $state = [];

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

        $state['ads_fingerprint'] = $this->adsFingerprint($campaign);

        return $state;
    }

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