<?php

namespace App\Campaigns\Platforms;

use App\Campaigns\Objectives;
use App\Models\Campaign;
use App\Models\PlatformConnection;

/**
 * Meta's rules, as the API actually enforces them.
 *
 * Most of these were learned from rejections rather than documentation, so the
 * reason each one exists is recorded next to it.
 */
class MetaRules implements PlatformRules
{
    public function key(): string
    {
        return 'meta';
    }

    public function name(): string
    {
        return 'Meta';
    }

    /**
     * Delegated rather than listed again.
     *
     * These were two lists of the same thing that had to agree, and the ad set
     * builder read one while the options the user clicked came from the other.
     */
    public function objectives(): array
    {
        return Objectives::SUPPORTED;
    }

    /**
     * Meta has no campaign type to choose.
     *
     * An ad set names its placements and that is the whole of it, so there is
     * nothing to ask and the flow skips the question entirely.
     *
     * @return list<string>
     */
    public function campaignTypes(): array
    {
        return [];
    }

    /** @return array{label: string, hint: string} */
    public function describeCampaignType(string $type): array
    {
        return ['label' => $type, 'hint' => ''];
    }

    public function suggestedCampaignType(?Campaign $campaign = null): ?array
    {
        return null;
    }

    public function bidStrategyFor(string $intent, ?string $objective = null): ?string
    {
        return match (strtoupper($intent)) {
            'AUTOMATIC' => 'LOWEST_COST_WITHOUT_CAP',
            'TARGET_COST' => 'COST_CAP',
            'BID_CAP' => 'LOWEST_COST_WITH_BID_CAP',
            default => null,
        };
    }

    public function bidStrategies(): array
    {
        return Objectives::BID_STRATEGIES;
    }

    /** Meta's own constants are unreadable, so each carries the sentence that makes it a choice. */
    public function bidStrategyOptions(): array
    {
        return [
            [
                'value' => 'LOWEST_COST_WITHOUT_CAP',
                'label' => 'Highest volume',
                'hint' => 'Spend the budget for as many results as possible, at whatever they cost.',
                'sends' => 'Bid for highest volume',
            ],
            [
                'value' => 'COST_CAP',
                'label' => 'Cost per result goal',
                'hint' => 'Hold the average cost per result near a number you name.',
                'sends' => 'Use a cost per result goal',
            ],
            [
                'value' => 'LOWEST_COST_WITH_BID_CAP',
                'label' => 'Bid cap',
                'hint' => 'Never bid above a number you name. Tighter, and can spend less.',
                'sends' => 'Use a bid cap',
            ],
        ];
    }

    /** Only the uncapped strategy spends freely; the other two are defined by their number. */
    public function bidStrategyNeedsTarget(string $strategy): bool
    {
        return $strategy !== 'LOWEST_COST_WITHOUT_CAP';
    }

    public function objectiveFor(string $outcome): ?string
    {
        $objective = 'OUTCOME_'.strtoupper($outcome);

        return in_array($objective, $this->objectives(), true) ? $objective : null;
    }

    /** @return array{label: string, hint: string} */
    public function describeObjective(string $objective): array
    {
        return ['label' => Objectives::label($objective), 'hint' => Objectives::hint($objective)];
    }

    public function placements(): array
    {
        return ['facebook', 'instagram', 'messenger', 'audience_network', 'threads'];
    }

    public function callsToAction(): array
    {
        return ['SHOP_NOW', 'LEARN_MORE', 'SIGN_UP', 'GET_OFFER', 'SUBSCRIBE', 'CONTACT_US', 'BOOK_TRAVEL'];
    }

    /**
     * Meta's declared categories, as the API names them.
     *
     * NONE is ours rather than Meta's: the API takes an empty list, but the
     * user has to be able to say "none of these" and have it recorded as an
     * answer rather than as nobody having asked.
     *
     * @return list<string>
     */
    public function specialAdCategories(): array
    {
        return ['NONE', 'HOUSING', 'EMPLOYMENT', 'CREDIT', 'ISSUES_ELECTIONS_POLITICS'];
    }

    /**
     * Meta's own macros, filled in at click time.
     *
     * Still read from config so an account with its own naming convention can
     * override it, which is why the string was there in the first place.
     */
    public function trackingOptions(): array
    {
        $standard = (string) config('platforms.tracking.default_utm');

        return [
            [
                'value' => $standard,
                'label' => 'Standard tagging',
                'hint' => 'Source, medium, campaign and ad. Meta fills in the names.',
                'badge' => 'Suggested',
                'sends' => 'Use the standard UTM tagging',
            ],
            [
                'value' => $standard.'&utm_term={{placement}}',
                'label' => 'Standard, plus placement',
                'hint' => 'Also records whether the click came from feed, reels, stories and so on.',
                'sends' => 'Use the standard UTM tagging plus placement',
            ],
        ];
    }

    /**
     * Null: the system user token reaches many ad accounts.
     *
     * Which one the money comes out of is a real question with real
     * consequences, so it stays a step.
     */
    public function defaultAdAccountId(): ?string
    {
        return null;
    }

    public function minimumDailyBudget(): float
    {
        return 5.00;
    }

    /**
     * All four, because Meta really does offer all four.
     *
     * These used to live in SetCampaignBudget and were shown on every platform.
     * They are Meta's, in Meta's vocabulary, and they belong here.
     */
    public function budgetModes(): array
    {
        return [
            [
                'value' => 'adset:daily',
                'label' => 'Daily',
                'hint' => 'Spend this much every day until it is stopped.',
                'sends' => 'Use a daily budget on the ad set',
            ],
            [
                'value' => 'adset:lifetime',
                'label' => 'Lifetime',
                'hint' => 'Spend this much in total by the end date. Needs an end date.',
                'sends' => 'Use a lifetime budget on the ad set',
            ],
            [
                'value' => 'campaign:daily',
                'label' => 'Daily, Advantage campaign budget',
                'hint' => 'Meta splits one daily budget across ad sets. No difference while there is one.',
                'sends' => 'Use Advantage campaign budget with a daily amount',
            ],
            [
                'value' => 'campaign:lifetime',
                'label' => 'Lifetime, Advantage campaign budget',
                'hint' => 'One total across ad sets, spent by the end date. Needs an end date.',
                'sends' => 'Use Advantage campaign budget with a lifetime amount',
            ],
        ];
    }

    /**
     * Meta truncates rather than refuses.
     *
     * An over-long headline is accepted, shortened, and served. The ad runs
     * looking broken and nothing in the API says so, which is why this is
     * checked here rather than left to the platform.
     *
     * @return array<string, int>
     */
    public function copyLimits(): array
    {
        return [
            'headline' => 40,
            'description' => 125,
            'primary_text' => 125,
        ];
    }

    /**
     * Where an objective is actually allowed to run.
     *
     * Search results carry no image, so an image ad set naming the search
     * placement is refused. Audience Network is off-platform inventory and is
     * not offered for engagement, which is engagement with a Meta post.
     *
     * @return list<string>
     */
    public function placementsFor(string $objective): array
    {
        return match ($objective) {
            'OUTCOME_ENGAGEMENT' => ['facebook', 'instagram', 'threads'],
            'OUTCOME_AWARENESS' => ['facebook', 'instagram', 'messenger', 'audience_network', 'threads'],
            default => $this->placements(),
        };
    }

    public function optimizationGoal(string $objective): ?string
    {
        return Objectives::supports($objective) ? Objectives::optimizationGoal($objective) : null;
    }

    /**
     * Sending PURCHASE under a leads objective is rejected as "conversion event
     * unavailable" (subcode 2446814), so the event is derived rather than asked for.
     */
    public function conversionEvent(string $objective): ?string
    {
        return Objectives::conversionEvent($objective);
    }

    public function requiresPixel(string $objective): bool
    {
        return $this->conversionEvent($objective) !== null;
    }

    /** Every Meta ad is published as a Page, so one always has to be chosen. */
    public function requiresPage(): bool
    {
        return true;
    }

    /** Every Meta ad is built around a creative; there is no text-only ad. */
    public function requiresCreativeAsset(?Campaign $campaign = null): bool
    {
        return true;
    }

    /** @return list<string> */
    public function additionalProblems(Campaign $campaign): array
    {
        $problems = [];

        // Naming a publisher platform switches off automatic placement, and Meta
        // then rejects the ad set unless positions within each platform are named
        // too. The spec fills those in, but only for platforms it knows.
        $unknown = array_diff((array) ($campaign->placements ?? []), $this->placements());

        if ($unknown !== []) {
            $problems[] = 'placement not supported on Meta: '.implode(', ', $unknown);
        }

        // A schedule that ends before it starts is accepted by our own checks but
        // rejected on create, after the campaign already exists.
        if ($campaign->starts_at && $campaign->ends_at && $campaign->ends_at->lte($campaign->starts_at)) {
            $problems[] = 'end date must be after the start date';
        }

        return $problems;
    }

    /**
     * Budget, bid and schedule can be updated in place. Creative cannot: Meta
     * treats an ad's creative as immutable, so changing copy or image means a
     * new creative and a new ad rather than an edit.
     *
     * @return list<string>
     */
    /**
     * What Meta needs that no other platform does.
     *
     * The landing page, the money, the dates, the creatives and the copy are
     * all absent on purpose: those are the brief's, asked once however many
     * platforms the campaign runs on.
     *
     * Ordered so that things which unlock other things come first. A Page and a
     * pixel cannot be listed before an ad account is chosen, and the objective
     * decides whether a pixel is wanted at all.
     */
    public function steps(): array
    {
        return [
            ['key' => 'ad_account', 'label' => 'ad account', 'tool' => 'list_ad_accounts', 'offers' => true,
                'ask' => 'Which ad account should this campaign run on?'],
            ['key' => 'objective', 'label' => 'objective', 'tool' => 'choose_objective', 'offers' => true,
                'ask' => 'What should this campaign optimise for?'],
            ['key' => 'special_ad_categories', 'label' => 'special ad category', 'tool' => 'declare_ad_category', 'offers' => true,
                'ask' => 'Does this campaign advertise housing, employment, credit or social issues?'],
            ['key' => 'page', 'label' => 'Facebook Page', 'tool' => 'choose_page', 'offers' => true,
                'ask' => 'Which Page should the ads be published as?'],
            ['key' => 'pixel', 'label' => 'pixel', 'tool' => 'choose_pixel', 'offers' => true,
                'ask' => 'Which pixel should conversions be attributed to?'],
            ['key' => 'bidding', 'label' => 'bid strategy', 'tool' => 'set_bidding', 'offers' => true,
                'ask' => 'How should it bid?'],
            // Countries come from the brief and are already answered by the
            // time this is reached; what is left here is Meta's own placements
            // and devices, which mean nothing to another platform.
            ['key' => 'targeting', 'label' => 'countries and placements', 'tool' => 'set_targeting', 'offers' => true,
                'ask' => 'Where should this run, and on which devices?'],
            // After targeting, because whether it applies depends on the placements.
            ['key' => 'instagram', 'label' => 'Instagram account', 'tool' => 'choose_instagram_account', 'offers' => true,
                'ask' => 'Which Instagram account should the Instagram ads run as?'],
            // Per platform rather than shared: the same landing page is tagged
            // differently depending on where the click came from.
            // No utm step. Click tracking is set from the platform's own
            // macros when the campaign is created and the buyer is told,
            // rather than asked to choose the value that was going to be
            // used anyway. set_tracking still changes it on request.
        ];
    }

    public function isConfigured(): bool
    {
        // Check database connection first, fall back to config system user token.
        return filled(PlatformConnection::shared('meta')?->access_token ?: config('platforms.meta.token'));
    }

    public function editableAfterPublish(): array
    {
        return ['name', 'budget', 'bid_strategy', 'bid_amount', 'starts_at', 'ends_at'];
    }

    public function supportedAssetRatios(): array
    {
        return ['1:1', '9:16', '1.91:1', '4:5'];
    }
}
