<?php

namespace App\Campaigns;

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

/**
 * Decides whether a campaign may be published.
 *
 * Plain code on purpose. The agent may request publication; only this grants
 * it. Every reason is phrased so it can be shown to the user unchanged, because
 * the same list drives both the prompt and whatever the interface renders.
 *
 * The shared checks below apply everywhere. Anything a platform disagrees about
 * comes from its own rules, so this class never learns which platform it is
 * looking at and a new platform inherits every shared protection by existing.
 */
class PublishGate
{
    public function __construct(
        private readonly Workspace $workspace,
        private readonly Platforms $platforms,
    ) {}

    /**
     * @return list<string> reasons publishing is blocked; empty means allowed
     */
    public function check(Campaign $campaign): array
    {
        if (! $this->platforms->supports($campaign->platform)) {
            return ["[{$campaign->platform}] is not a platform this product supports"];
        }

        $rules = $this->platforms->for($campaign);

        return array_values(array_filter([
            ...$this->basics($campaign, $rules),
            ...$this->share($campaign),
            ...$this->schedule($campaign),
            ...$this->audience($campaign),
            ...$this->ads($campaign, $rules),
            ...$this->account($campaign, $rules),
            ...$this->destination($campaign),
            ...$this->provenance($campaign),
            ...$rules->additionalProblems($campaign),
        ]));
    }

    public function passes(Campaign $campaign): bool
    {
        return $this->check($campaign) === [];
    }

    /** @return list<string|null> */
    private function basics(Campaign $campaign, $rules): array
    {
        $ceiling = BudgetCeiling::for($campaign);
        $floor = $rules->minimumDailyBudget();

        return [
            // First, so it is the only thing reported once a campaign is live.
            // Everything below describes how to make it publishable, which is
            // misleading for something already on the platform.
            $campaign->isLive()
                ? 'already published as '.$campaign->external_campaign_id
                : null,

            blank($campaign->name) ? 'campaign name not set' : null,

            // Not defaulted. Every campaign so far declared "no special
            // category" because the write paths hardcoded an empty list, which
            // for a housing, employment or credit ad is a false declaration to
            // Meta rather than a missing field. Somebody has to answer.
            match (true) {
                $rules->specialAdCategories() === [] => null,
                $campaign->special_ad_categories === null => 'special ad category not declared. '
                    .'Ask whether this is housing, employment, credit or social issues, and say NONE if it is none of them',
                $this->unknownCategories($campaign, $rules) !== [] => sprintf(
                    'unknown special ad category: %s. Allowed: %s',
                    implode(', ', $this->unknownCategories($campaign, $rules)),
                    implode(', ', $rules->specialAdCategories()),
                ),
                default => null,
            },

            match (true) {
                blank($campaign->objective) => 'objective not set',
                ! in_array($campaign->objective, $rules->objectives(), true) => sprintf(
                    '%s does not support the %s objective. Supported: %s',
                    $rules->name(),
                    $campaign->objective,
                    implode(', ', $rules->objectives()),
                ),
                default => null,
            },

            match (true) {
                blank($campaign->landing_url) => 'landing page URL not set',
                ! filter_var($campaign->landing_url, FILTER_VALIDATE_URL) => 'landing page URL is not valid',
                default => null,
            },

            ...$this->budget($campaign, $floor, $ceiling, $rules),

            match (true) {
                blank($campaign->bid_strategy) => null,
                ! in_array($campaign->bid_strategy, $rules->bidStrategies(), true) => sprintf(
                    '%s does not support the %s bid strategy. Supported: %s',
                    $rules->name(),
                    $campaign->bid_strategy,
                    implode(', ', $rules->bidStrategies()),
                ),
                // Asked of the platform. Written as a comparison against Meta's
                // uncapped strategy, it demanded a target cost from every
                // Google strategy including the ones that take none.
                $rules->bidStrategyNeedsTarget((string) $campaign->bid_strategy)
                    && blank($campaign->bid_amount) => 'bid strategy needs a target cost',
                default => null,
            },
        ];
    }

    /**
     * The budget, read against the mode it was set in.
     *
     * A lifetime budget is a total rather than a rate, so neither the floor nor
     * the ceiling can be compared against it directly: 300 over thirty days is
     * ten a day, not a 300 daily spend. Both limits are applied to the implied
     * daily amount, which is also how Meta reads its own minimum.
     *
     * @return list<string|null>
     */
    private function budget(Campaign $campaign, float $floor, ?float $ceiling, $rules): array
    {
        $lifetime = $campaign->usesLifetimeBudget();
        $label = $lifetime ? 'lifetime budget' : 'daily budget';

        if (blank($campaign->budget) || (float) $campaign->budget <= 0) {
            return [$label.' not set'];
        }

        // Meta refuses a lifetime budget with no end time, and refuses it after
        // the campaign exists rather than during validation.
        if ($lifetime && ! $campaign->ends_at) {
            return ['a lifetime budget needs an end date, since it is the total to spend by then'];
        }

        // No ceiling configured for this account's currency. Refuse rather than
        // fall back to one expressed in a different currency, which would be a
        // spending limit that means nothing.
        if ($ceiling === null) {
            return [BudgetCeiling::missingReason($campaign)];
        }

        $days = $lifetime ? $this->days($campaign) : 1;
        $perDay = (float) $campaign->budget / max($days, 1);

        $tooLow = $lifetime
            ? sprintf(
                'lifetime budget of %.2f over %d days is %.2f a day, below %s\'s %.2f minimum',
                $campaign->budget, $days, $perDay, $rules->name(), $floor,
            )
            : sprintf('daily budget of %.2f is below %s\'s %.2f minimum', $campaign->budget, $rules->name(), $floor);

        $tooHigh = $lifetime
            ? sprintf(
                'lifetime budget of %.2f over %d days is %.2f a day, above the %.2f ceiling this product allows',
                $campaign->budget, $days, $perDay, $ceiling,
            )
            : sprintf('daily budget of %.2f is above the %.2f ceiling this product allows', $campaign->budget, $ceiling);

        return [
            $perDay < $floor ? $tooLow : null,
            $perDay > $ceiling ? $tooHigh : null,
        ];
    }

    /**
     * What every platform together would spend, against what was agreed once.
     *
     * The per-campaign budget check above is blind to this. Each share can sit
     * happily between the floor and the ceiling while the shares together spend
     * several times the number the buyer actually said. Seen on 21 Sep: a brief
     * total of 5.00 a day with two campaigns holding 10.00 each, and nothing
     * anywhere reported a problem, so publishing both would have spent four
     * times the agreed amount.
     *
     * Only over-allocation is a problem. Spending less than the total is a
     * choice, and holding some back is a normal one.
     *
     * Read from the raw attributes on purpose: {@see ResolvesFromBrief} makes
     * $campaign->budget fall back to the brief's total, so summing the accessor
     * would count the total once per platform and call an unsplit budget
     * perfectly balanced.
     *
     * @return list<string|null>
     */
    private function share(Campaign $campaign): array
    {
        $brief = $campaign->brief;

        if (! $brief || (float) $brief->budget <= 0) {
            return [];
        }

        $siblings = $brief->campaigns()->get();

        // One platform spending the whole total is the total, not a share.
        if ($siblings->count() < 2) {
            return [];
        }

        // A lifetime total and a daily rate are different units, so comparing
        // them would produce a confident wrong number. Say nothing instead.
        $mode = $brief->budget_mode ?: 'daily';

        if ($siblings->contains(fn (Campaign $c): bool => $c->budgetMode() !== $mode)) {
            return [];
        }

        $label = $mode === 'lifetime' ? 'lifetime' : 'a day';

        if ($brief->needsBudgetSplit()) {
            return [sprintf(
                'the %.2f %s total has not been split across the %d platforms yet, '
                .'so each would spend it in full',
                $brief->budget, $label, $siblings->count(),
            )];
        }

        $total = (float) $brief->budget;
        $sum = (float) $siblings->sum(fn (Campaign $c): float => (float) $c->getAttributes()['budget']);

        // Compared in minor units. Three shares of a tenth of a penny each are
        // not an overspend, and float addition should not invent one.
        if (round($sum * 100) <= round($total * 100)) {
            return [];
        }

        return [sprintf(
            'the shares across %d platforms add up to %.2f %s, over the %.2f %s total set for this brief',
            $siblings->count(), $sum, $label, $total, $label,
        )];
    }

    /** How long a lifetime budget has to last, counted from the start or from now. */
    private function days(Campaign $campaign): int
    {
        $from = $campaign->starts_at && $campaign->starts_at->isFuture() ? $campaign->starts_at : now();

        return max(1, (int) ceil($from->diffInDays($campaign->ends_at, absolute: false)));
    }

    /**
     * A campaign that ends before it starts, or in the past, is accepted by the
     * platform's own validation only sometimes, and rejected on create after
     * the campaign already exists.
     *
     * @return list<string|null>
     */
    /**
     * Categories the platform does not recognise.
     *
     * @return list<string>
     */
    private function unknownCategories(Campaign $campaign, $rules): array
    {
        return array_values(array_diff(
            (array) ($campaign->special_ad_categories ?? []),
            $rules->specialAdCategories(),
        ));
    }

    private function schedule(Campaign $campaign): array
    {
        return [
            $campaign->starts_at && $campaign->ends_at && $campaign->ends_at->lte($campaign->starts_at)
                ? 'end date must be after the start date' : null,

            $campaign->ends_at && $campaign->ends_at->isPast()
                ? 'end date is in the past' : null,
        ];
    }

    /** @return list<string|null> */
    private function audience(Campaign $campaign): array
    {
        $rules = $this->platforms->for($campaign);
        $chosen = (array) ($campaign->placements ?? []);

        // A placement the objective cannot use is refused at the ad set, after
        // the campaign already exists.
        $impossible = filled($campaign->objective)
            ? array_diff($chosen, $rules->placementsFor($campaign->objective))
            : [];

        return [
            empty($campaign->locations['countries'] ?? []) ? 'target country not set' : null,

            $impossible !== [] ? sprintf(
                '%s cannot run on %s. Supported for this objective: %s',
                $campaign->objective,
                implode(', ', $impossible),
                implode(', ', $rules->placementsFor($campaign->objective)),
            ) : null,

            // An Instagram placement with no Instagram account runs the ads
            // under the Facebook Page's name and picture. Meta allows it, so
            // nothing fails; it simply does not look like the brand. Empty
            // string is the advertiser having been asked and having none.
            in_array('instagram', $chosen, true) && $campaign->instagram_account_id === null
                ? 'Instagram is a chosen placement but no Instagram account has been picked, '
                    .'so those ads would run under the Facebook Page' : null,
        ];
    }

    /**
     * Where the click actually lands.
     *
     * Both of these are part of the creative on Meta, so neither can be fixed
     * after publish without building a new creative and a new ad.
     *
     * @return list<string|null>
     */
    private function destination(Campaign $campaign): array
    {
        return [
            // Shape only. A malformed query string breaks every click on the
            // ad, and "utm_source=facebook&&" looks fine to a human.
            $campaign->utm !== null && $campaign->utm !== ''
            && ! preg_match('/^[^=&\s]+=[^&]*(&[^=&\s]+=[^&]*)*$/', $campaign->utm)
                ? 'click tracking is not a valid query string, so the destination URL would be broken' : null,

            // Only meaningful once there is an objective to disagree with. An
            // adopted campaign can carry an optimisation goal read off its ad
            // set while Meta returned no objective, and without this the reason
            // reads "does not match the  objective" alongside "objective not set".
            filled($campaign->objective)
            && $campaign->optimization_goal !== null
            && $campaign->optimization_goal !== $this->platforms->for($campaign)->optimizationGoal((string) $campaign->objective)
                ? sprintf(
                    'optimisation goal %s does not match the %s objective, which Meta refuses as "conversion event unavailable"',
                    $campaign->optimization_goal,
                    $campaign->objective,
                ) : null,
        ];
    }

    /** @return list<string|null> */
    private function ads(Campaign $campaign, $rules): array
    {
        $ads = $campaign->ads()->with('asset.platformRefs')->get();
        $limit = (int) config('platforms.guardrails.max_ads_per_publish');

        $problems = [
            $ads->isEmpty() ? 'no ad copy written yet' : null,
            $ads->count() > $limit
                ? sprintf('%d ads, but at most %d may be published at once', $ads->count(), $limit)
                : null,
        ];

        foreach ($ads as $index => $ad) {
            $label = $ads->count() > 1 ? 'ad '.($index + 1).': ' : '';

            $problems[] = blank($ad->headline) ? $label.'headline missing' : null;
            $problems[] = blank($ad->primary_text) ? $label.'primary text missing' : null;

            // Over the limit the platform does not refuse the ad, it truncates
            // it and runs it. Nobody finds out until someone reads a live ad
            // and sees a headline ending mid-word.
            foreach ($rules->copyLimits() as $field => $longest) {
                $value = (string) $ad->getAttribute($field);

                $problems[] = mb_strlen($value) > $longest ? sprintf(
                    '%s%s is %d characters, over %s\'s %d limit. %s shortens it rather than refusing it',
                    $label,
                    str_replace('_', ' ', $field),
                    mb_strlen($value),
                    $rules->name(),
                    $longest,
                    $rules->name(),
                ) : null;
            }

            $problems[] = match (true) {
                // A platform with no call to action field has nothing to be
                // missing. Google writes the button itself from the final URL
                // and the copy, so demanding one here was a block no answer
                // could clear, the same shape as asking Google for a Page.
                $rules->callsToAction() === [] => null,
                blank($ad->cta) => $label.'call to action missing',
                ! in_array($ad->cta, $rules->callsToAction(), true) => sprintf(
                    '%s%s is not a call to action %s accepts',
                    $label,
                    $ad->cta,
                    $rules->name(),
                ),
                default => null,
            };

            // Only where an ad is built around one. A Google responsive search
            // ad is headlines, descriptions and a URL, with no asset anywhere,
            // so this was a block that no upload could ever clear.
            $problems[] = ! $rules->requiresCreativeAsset($campaign) || $this->assetReady($campaign, $ad)
                ? null : $label.'creative not uploaded to the platform yet';
        }

        return $problems;
    }

    /** @return list<string|null> */
    private function account(Campaign $campaign, $rules): array
    {
        return [
            blank($campaign->ad_account_id) ? 'ad account not verified' : null,
            $rules->requiresPage() && blank($campaign->page_id) ? 'Page not selected' : null,
            $campaign->objective
            && $rules->requiresPixel($campaign->objective)
            && blank($campaign->pixel_id)
                ? 'pixel not selected, and a conversion objective requires one' : null,
        ];
    }

    /**
     * A value guessed after a lookup failed must be confirmed before it can
     * spend money. This is the check that makes provenance worth recording.
     *
     * @return list<string>
     */
    private function provenance(Campaign $campaign): array
    {
        return collect($this->workspace->unconfirmed($campaign))
            // A generated name is recorded as a fallback so the panel can show
            // that nobody chose it, but it cannot spend money wrongly, and the
            // agent is told never to ask for one. Blocking on it left the
            // guided flow with a step no click could clear: publishing refused
            // until the name was confirmed, and nothing ever asked.
            ->reject(fn (string $field): bool => $field === 'name')
            ->map(fn (string $field): string => str_replace('_', ' ', $field).' was guessed and needs confirming')
            ->values()
            ->all();
    }

    /**
     * Whether this ad's creative has reached the platform.
     *
     * An ad with no asset at all used to pass on LinkedIn. That is the one case
     * this check exists for, and LinkedInRules::requiresCreativeAsset() returns
     * true, so the exemption only ever fired where the answer should have been
     * no. The publisher then had nothing to sponsor and built a reference out
     * of a database row id to fill the gap.
     *
     * Whether a platform needs a creative at all is the platform's answer, and
     * the caller already asks requiresCreativeAsset() before reaching here.
     */
    private function assetReady(Campaign $campaign, $ad): bool
    {
        return (bool) ($ad->asset
            && $campaign->ad_account_id
            && $ad->asset->refFor($campaign->platform, $campaign->ad_account_id)?->reference());
    }
}
