<?php

namespace App\Campaigns;

use App\Agent\Workspace;
use App\Models\ArtifactFieldSource;
use App\Models\Campaign;
use App\Services\Meta\Meta;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use RuntimeException;

/**
 * Adopt a campaign that already exists on the platform.
 *
 * Everything the product could touch until now, it had built itself. Most of
 * what a media buyer actually works on was made elsewhere, and was untouchable:
 * no local row, no ids, and above all no baseline to diff against.
 *
 * The baseline is the point. published_state is what the platform is believed
 * to hold, and every later edit is a comparison against it. Adopting without
 * one would make the first edit look like a change to every field at once.
 *
 * Values are read over Graph, never the MCP. The MCP renders them for people,
 * "$25.00 USD" rather than 2500, so a value read there cannot be compared with
 * a new one or written back.
 */
class CampaignImporter
{
    public function __construct(
        private readonly Meta $meta,
        private readonly PendingChanges $pending,
        private readonly Workspace $workspace,
    ) {}

    /**
     * Bring a platform campaign under management.
     *
     * Idempotent by external id: adopting the same campaign twice returns the
     * row already held rather than making a second one that would then fight
     * with the first over the same objects.
     */
    public function adopt(string $campaignId, Model $owner): Campaign
    {
        if ($existing = Campaign::where('external_campaign_id', $campaignId)->first()) {
            return $existing;
        }

        $remote = $this->meta->campaign($campaignId);
        $adSets = $this->meta->adSets($campaignId);

        if (! isset($remote['id'])) {
            throw new RuntimeException("Meta returned no campaign for {$campaignId}.");
        }

        // Budget, bidding, schedule and targeting live on the ad set, not the
        // campaign. Multiple ad sets are common; the first is adopted and the
        // rest are recorded so nothing silently disappears.
        $adSet = $adSets[0] ?? [];
        $budget = $this->budget($remote, $adSet);

        $campaign = new Campaign([
            'user_id' => $owner->getKey(),
            'name' => $remote['name'] ?? 'Imported campaign',
            'platform' => 'meta',
            'status' => 'published',
            'ad_account_id' => $this->accountId($remote),
            'objective' => $remote['objective'] ?? null,
            'special_ad_categories' => $this->declaredCategories($remote),
            'optimization_goal' => $adSet['optimization_goal'] ?? null,
            'pixel_id' => $adSet['promoted_object']['pixel_id'] ?? null,
            'budget' => $budget['amount'],
            'budget_level' => $budget['level'],
            'budget_mode' => $budget['mode'],
            'bid_strategy' => $remote['bid_strategy'] ?? $adSet['bid_strategy'] ?? null,
            'bid_amount' => $this->money($adSet['bid_amount'] ?? null),
            'starts_at' => $this->time($adSet['start_time'] ?? null),
            // A campaign-level lifetime budget puts the end on the campaign as
            // stop_time, and the ad set may carry none.
            'ends_at' => $this->time($adSet['end_time'] ?? $remote['stop_time'] ?? null),
            'locations' => $this->locations($adSet),
            'placements' => $this->placements($adSet),
            'external_campaign_id' => $campaignId,
            'external_ids' => $this->ids($campaignId, $adSets),
            'last_synced_at' => now(),
        ]);

        $campaign->save();

        // The baseline, written from what was just read, so the campaign starts
        // with nothing pending rather than appearing to have changed entirely.
        $campaign->forceFill(['published_state' => $this->pending->snapshot($campaign)])->save();

        $this->recordWhereItCameFrom($campaign);

        return $campaign->fresh();
    }

    /**
     * Where this campaign's budget actually is, and what kind it is.
     *
     * The campaign is looked at first: if it holds a budget then it is using
     * Advantage campaign budget, and the ad set has none. Reading the ad set
     * first would report a campaign-budget campaign as having no budget, and
     * any later edit would then be pushed at the wrong object.
     *
     * @param  array<string,mixed>  $remote
     * @param  array<string,mixed>  $adSet
     * @return array{amount: float|null, level: string, mode: string}
     */
    private function budget(array $remote, array $adSet): array
    {
        foreach ([['campaign', $remote], ['adset', $adSet]] as [$level, $object]) {
            foreach (['daily' => 'daily_budget', 'lifetime' => 'lifetime_budget'] as $mode => $field) {
                if ($amount = $this->money($object[$field] ?? null)) {
                    return ['amount' => $amount, 'level' => $level, 'mode' => $mode];
                }
            }
        }

        return ['amount' => null, 'level' => 'adset', 'mode' => 'daily'];
    }

    /**
     * Say that these values came from the platform, because they did.
     *
     * Nothing was recorded at all, so every row of an adopted campaign showed
     * the grey dot the panel uses for "not set" while displaying a real value
     * read from Meta. The dots exist to separate what a person chose from what
     * was guessed, and an adopted campaign is the case where neither applies:
     * the platform is the source, and there is a colour for exactly that.
     */
    private function recordWhereItCameFrom(Campaign $campaign): void
    {
        $fields = [
            'name', 'objective', 'optimization_goal', 'special_ad_categories',
            'ad_account_id', 'pixel_id', 'budget', 'budget_level', 'budget_mode',
            'bid_strategy', 'bid_amount', 'starts_at', 'ends_at', 'locations', 'placements',
        ];

        foreach ($fields as $field) {
            // Only what Meta actually returned. A field it had nothing for is
            // still unanswered, and saying otherwise would hide a real gap.
            if (blank($campaign->getAttribute($field))) {
                continue;
            }

            $this->workspace->recordSource($campaign, $field, ArtifactFieldSource::PLATFORM_RETURNED);
        }
    }

    /**
     * The special ad category declaration, as it stands on the platform.
     *
     * Adopted rather than asked for again. Whoever built the campaign already
     * made this declaration to Meta, and it is a statement about the
     * advertising, not a preference: asking a second time invites a different
     * answer from the one Meta is holding.
     *
     * Meta writes "no category" as an empty list, and null here means nobody
     * has ever been asked, so the two cannot share a representation. An empty
     * list from Meta becomes our own NONE, which is an answer. The field
     * missing from the response is the only genuine unknown, and leaves the
     * flow asking, which is the safe direction.
     *
     * @param  array<string,mixed>  $remote
     * @return list<string>|null
     */
    private function declaredCategories(array $remote): ?array
    {
        if (! array_key_exists('special_ad_categories', $remote)) {
            return null;
        }

        $declared = array_values(array_filter((array) $remote['special_ad_categories']));

        return $declared === [] ? ['NONE'] : $declared;
    }

    /**
     * Money as Meta states it.
     *
     * Budgets and bids come back as minor units in a string, "2500" for $25.
     * Read as a number it becomes a budget a hundred times too large, which is
     * the kind of mistake that only shows up on the invoice.
     */
    private function money(int|string|null $minorUnits): ?float
    {
        return $minorUnits === null || $minorUnits === '' ? null : ((int) $minorUnits) / 100;
    }

    private function time(?string $value): ?string
    {
        return $value ? Carbon::parse($value)->toDateTimeString() : null;
    }

    /** @return array<string,mixed>|null */
    private function locations(array $adSet): ?array
    {
        $countries = $adSet['targeting']['geo_locations']['countries'] ?? null;

        return $countries ? ['countries' => array_values($countries)] : null;
    }

    /**
     * Placements as we hold them: the platforms, not every position.
     *
     * Meta returns publisher_platforms plus a list of positions per platform.
     * Only the platforms are ours to edit, so only they are adopted, and the
     * positions are left alone on the platform rather than half represented.
     *
     * @return list<string>|null
     */
    private function placements(array $adSet): ?array
    {
        $platforms = $adSet['targeting']['publisher_platforms'] ?? null;

        return $platforms ? array_values($platforms) : null;
    }

    private function accountId(array $remote): ?string
    {
        $id = $remote['account_id'] ?? null;

        // A prefix strip. ltrim() with a character list would eat any leading
        // a, c, t or underscore, so an account id that happened to start with
        // one would come back mangled.
        return $id ? 'act_'.preg_replace('/^act_/', '', (string) $id) : null;
    }

    /**
     * @param  list<array<string,mixed>>  $adSets
     * @return array<string,mixed>
     */
    private function ids(string $campaignId, array $adSets): array
    {
        $ids = ['campaign_id' => $campaignId];

        if (isset($adSets[0]['id'])) {
            $ids['adset_id'] = $adSets[0]['id'];
        }

        // Recorded, not managed. Editing reaches the adopted ad set only, and
        // saying so is better than quietly ignoring the others.
        if (count($adSets) > 1) {
            $ids['other_adset_ids'] = collect($adSets)->skip(1)->pluck('id')->values()->all();
        }

        return $ids;
    }
}
