<?php

namespace App\Services\Meta;

use App\Campaigns\Objectives;

/**
 * Everything an ad set needs, in one place, so the two transports build the
 * same request from the same source rather than drifting apart.
 */
readonly class AdSetSpec
{
    /**
     * @param  int  $budgetMinorUnits  ignored when the campaign holds the budget
     * @param  string  $budgetLevel  'adset' or 'campaign'
     * @param  string  $budgetMode  'daily' or 'lifetime'
     */
    public function __construct(
        public string $adAccountId,
        public string $campaignId,
        public string $name,
        public int $budgetMinorUnits,
        public string $optimizationGoal,
        public array $targeting,
        public string $pageId,
        public ?string $pixelId = null,
        public ?string $conversionEvent = null,
        public string $bidStrategy = 'LOWEST_COST_WITHOUT_CAP',
        public ?int $bidAmountCents = null,
        public ?\DateTimeInterface $startsAt = null,
        public ?\DateTimeInterface $endsAt = null,
        public string $budgetLevel = 'adset',
        public string $budgetMode = 'daily',
    ) {}

    /**
     * Budget and bidding, which move together.
     *
     * Under Advantage campaign budget the money and the strategy both sit on
     * the campaign, and repeating either here is rejected rather than ignored.
     * The cost cap amount stays on the ad set in both arrangements, because it
     * is a per-ad-set target even when Meta is deciding the split.
     *
     * @return array<string, mixed>
     */
    public function budgetFields(): array
    {
        if ($this->budgetLevel === 'campaign') {
            return array_filter(['bid_amount' => $this->bidAmountCents]);
        }

        return array_filter([
            $this->budgetMode === 'lifetime' ? 'lifetime_budget' : 'daily_budget' => $this->budgetMinorUnits,
            'bid_strategy' => $this->bidStrategy,
            'bid_amount' => $this->bidAmountCents,
        ], fn (mixed $value): bool => $value !== null);
    }

    /**
     * When the ad set runs.
     *
     * ISO 8601 with an offset, deliberately. Meta reads a bare local-format
     * string in the ad account's own timezone, which is frequently not ours, so
     * an unqualified string quietly shifts the start by hours.
     *
     * @return array<string, string>
     */
    public function schedule(): array
    {
        return array_filter([
            'start_time' => $this->startsAt?->format(\DateTimeInterface::ATOM),
            'end_time' => $this->endsAt?->format(\DateTimeInterface::ATOM),
        ]);
    }

    /**
     * Meta's targeting spec.
     *
     * Naming a publisher platform switches off automatic placement, so the
     * positions within each platform have to be named too or the ad set is
     * rejected.
     */
    public function targetingSpec(): array
    {
        $spec = [
            'geo_locations' => ['countries' => array_values($this->targeting['countries'])],
            'age_min' => $this->targeting['age_min'] ?? 18,
            'age_max' => $this->targeting['age_max'] ?? 65,
            'targeting_automation' => ['advantage_audience' => 0],
        ];

        if (! empty($this->targeting['genders'])) {
            $spec['genders'] = array_values($this->targeting['genders']);   // 1 male, 2 female
        }

        if (! empty($this->targeting['devices'])) {
            $spec['device_platforms'] = array_values($this->targeting['devices']);
        }

        if (! empty($this->targeting['placements'])) {
            $spec['publisher_platforms'] = array_values($this->targeting['placements']);

            foreach ($this->targeting['placements'] as $platform) {
                if ($positions = self::POSITIONS[$platform] ?? null) {
                    $spec[$platform === 'audience_network' ? 'audience_network_positions' : "{$platform}_positions"] = $positions;
                }
            }
        }

        return $spec;
    }

    /**
     * What the ad set optimises toward: a pixel event, the Page, or nothing.
     *
     * Nothing is a real answer for reach, where there is no object being
     * optimised toward, only people being counted, and Meta rejects a promoted
     * object sent against it.
     */
    public function promotedObject(): array
    {
        if ($this->pixelId && $this->conversionEvent) {
            return ['pixel_id' => $this->pixelId, 'custom_event_type' => $this->conversionEvent];
        }

        return Objectives::needsPromotedObject($this->optimizationGoal)
            ? ['page_id' => $this->pageId]
            : [];
    }

    /**
     * The positions named within each platform.
     *
     * facebook no longer lists video_feeds. Meta retired it and now refuses the
     * whole ad set rather than ignoring it: "Facebook video feeds placement is
     * deprecated for this API version and cannot be selected" (subcode
     * 2490562). Since naming any publisher platform switches off automatic
     * placement and forces these lists to be sent, one retired position was
     * enough to make every campaign choosing Facebook fail at the ad set.
     *
     * Meta retires positions without notice, so anything added here should be
     * confirmed against a real ad set create rather than against the docs.
     */
    private const POSITIONS = [
        'facebook' => ['feed', 'story', 'facebook_reels', 'marketplace', 'search'],
        'instagram' => ['stream', 'story', 'reels', 'explore'],
        'messenger' => ['messenger_home', 'story'],
        'audience_network' => ['classic', 'rewarded_video'],
        'threads' => ['threads_feed'],
    ];
}
