<?php

namespace App\Agent\Tools\Campaign;

use App\Agent\Choices;
use App\Agent\Workspace;
use App\Campaigns\Briefs;
use App\Campaigns\BudgetCeiling;
use App\Campaigns\Platforms\Platforms;
use App\Models\ArtifactFieldSource;
use App\Models\Campaign;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Collection;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

/**
 * Set the daily budget on the campaign in focus.
 *
 * The second half of the worked example: a tool that acts on whatever is in
 * focus rather than taking an id. This is what makes "make it thirty a day"
 * work in a conversation holding three campaigns.
 *
 * The ceiling here is a product limit, deliberately checked in plain code. The
 * model may propose any number; only this decides what is allowed.
 */
class SetCampaignBudget implements Tool
{
    /** Why no amount could be offered, when that is the case. */
    private ?string $noViableAmount = null;

    public function __construct(
        private readonly Workspace $workspace,
        private readonly Choices $choices,
        private readonly Platforms $platforms,
        private readonly Briefs $briefs,
    ) {}

    public function description(): Stringable|string
    {
        return 'Set the budget of the campaign currently in focus: how much, whether it is '
            .'spent per day or in total, and whether it sits on the campaign or the ad set. '
            .'Amount is in the account currency. Switch focus first if the user means a '
            .'different campaign.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'amount' => $schema->number()
                ->description('Budget as a decimal amount, for example 20 or 37.50. Omit to offer amounts for the user to pick.'),
            'mode' => $schema->string()
                ->description('daily for an amount per day, lifetime for a total to spend by the end date. Defaults to daily.'),
            'level' => $schema->string()
                ->description('Where the budget sits: adset for each ad set to hold its own, campaign for one '
                    .'budget shared across them, which Meta calls Advantage campaign budget. Not every platform '
                    .'offers both, and Google only has campaign budgets. Defaults to the platform\'s own.'),
            'confirmed' => $schema->boolean()
                ->description('Set true only after reading a large amount back to the user and hearing them '
                    .'confirm it. Amounts above the confirmation threshold are refused without this.'),
        ];
    }

    /**
     * How the budget works, as things to click rather than two questions.
     *
     * Meta asks level and mode separately and names them for its own features.
     * Crossed into one list they read as what the buyer is actually choosing,
     * and cost one click instead of two.
     *
     * The options come from the platform now. They were written here, in Meta's
     * words, and shown on every platform, so a Google buyer was asked to choose
     * between an ad set budget and "Advantage campaign budget" on a platform
     * that has neither, and three of the four answers could not be published.
     *
     * A platform offering one way of doing it is not asked: the single mode is
     * applied and the buyer only picks an amount.
     */
    private function offerStrategy(Campaign $campaign): void
    {
        $modes = $this->platforms->for($campaign)->budgetModes();

        if (count($modes) === 1) {
            $this->applySoleMode($campaign, $modes[0]['value']);

            return;
        }

        $this->choices->offer(
            field: 'budget_strategy',
            question: 'How should the budget work?',
            options: $modes,
        );
    }

    /**
     * The only way this platform takes a budget, applied without asking.
     *
     * Recorded as the platform's answer rather than the buyer's or the model's,
     * because it is neither: nobody was asked and nothing was inferred, the
     * platform simply has one way of doing it.
     */
    private function applySoleMode(Campaign $campaign, string $strategy): void
    {
        [$level, $mode] = array_pad(explode(':', $strategy, 2), 2, 'daily');

        $campaign->update(['budget_level' => $level, 'budget_mode' => $mode]);

        foreach (['budget_level', 'budget_mode'] as $field) {
            $this->workspace->recordSource($campaign, $field, ArtifactFieldSource::PLATFORM_RETURNED);
        }
    }

    /**
     * Amounts worth one click, kept inside what would actually be accepted.
     *
     * Offering a number the gate then refuses would be worse than offering
     * nothing, so the ceiling this product enforces bounds the list.
     */
    private function offerAmounts(Campaign $campaign, string $mode, float $ceiling): void
    {
        // The rungs are one campaign's share, so they are bounded by one
        // campaign's floor and ceiling.
        //
        // This used viableFloor(), which is a total across platforms, against
        // $ceiling, which is per campaign. On three platforms that asked for an
        // amount both >= 30 and <= 20, so the list came out empty and the
        // function returned in silence: the buyer was told "choose an amount
        // shown" with nothing shown, and had to guess. Seen on arb-dev, where
        // they typed 10 and it could never have worked.
        // Cleared first. The tool is resolved once per turn and the model can
        // call it twice in that turn, so a refusal set by the first call would
        // otherwise still be standing when the second one succeeds.
        $this->noViableAmount = null;

        $floor = $this->sharePerPlatform($campaign);
        $currency = $campaign->currency ?: 'USD';

        // A lifetime budget is a total, so daily rungs are the wrong ladder:
        // 20 as a whole campaign budget is not an amount anyone means.
        $amounts = $mode === 'lifetime'
            ? collect([100, 250, 500, 1000, 2500])
            : collect([5, 10, 20, 50, 100])->filter(fn (int $a): bool => $a >= $floor && $a <= $ceiling);

        // Nothing fits, so say why rather than showing an empty screen. It
        // means the floors and the ceiling leave no room, which is an
        // administrator's problem and not an amount the buyer can guess.
        if ($amounts->isEmpty()) {
            $this->noViableAmount = sprintf(
                'No amount works here: each platform needs at least %s a day and this product allows at most %s, '
                .'so there is nothing to offer. Say so and stop.',
                number_format($floor, 2),
                number_format($ceiling, 2),
            );

            return;
        }

        // One of them is recommended, and says why. The rungs alone are a
        // question with five equally weighted answers, and the buyer has no way
        // to know that on two platforms the small ones cannot be split: a 5.00
        // total across Meta and Google leaves 2.50 each and Meta will not run
        // below 5.00. That was discovered on dev by being refused at the gate
        // after everything else had been answered.
        $suggested = $mode === 'lifetime'
            ? $amounts->first()
            : $amounts->first(fn (int $a): bool => $a >= $this->comfortable($campaign, $floor)) ?? $amounts->last();

        $this->choices->offer(
            field: 'budget_amount',
            question: $mode === 'lifetime'
                ? 'How much in total? Pick one or type any amount.'
                : sprintf('What daily budget? Pick one or type any amount up to %s %s.', $currency, $ceiling),
            options: $amounts->values()->map(fn (int $a): array => [
                'value' => (string) $a,
                'label' => $currency.' '.$a,
                'hint' => $a === $suggested ? $this->whySuggested($campaign, $floor) : null,
                'badge' => $a === $suggested ? 'Suggested' : null,
                'sends' => $mode === 'lifetime'
                    ? 'Set the lifetime budget to '.$a
                    : 'Set the daily budget to '.$a,
            ])->all(),
        );
    }

    /**
     * The smallest total every platform on this brief could actually spend.
     *
     * The focused campaign's own floor is the wrong number when the amount is a
     * total to be divided: splitting 5.00 evenly across Meta and Google leaves
     * Meta 2.50, and Meta does not run below 5.00. Offering 5.00 there is
     * offering an answer the gate refuses, which the amounts list exists to
     * avoid.
     *
     * The highest floor times the number of platforms, because an even split is
     * what split_budget offers first and what most buyers take.
     */
    /**
     * What one platform has to be given, which is what a rung represents.
     *
     * viableFloor() below answers a different question, the smallest workable
     * total, and the two were being used interchangeably.
     */
    private function sharePerPlatform(Campaign $campaign): float
    {
        // ?? rather than ?:, because a floor of zero is an answer. Google's is
        // nothing, and ?: would treat that as "no platforms" and go looking for
        // a fallback it does not need.
        return (float) ($this->briefPlatforms($campaign)
            ->map(fn (Campaign $c): float => $this->platforms->for($c)->minimumDailyBudget())
            ->max() ?? $this->platforms->for($campaign)->minimumDailyBudget());
    }

    /**
     * The least this brief could spend and still run everywhere.
     *
     * Each platform's own floor added up, not the largest floor multiplied,
     * because Google's is nothing and LinkedIn's is ten: three platforms need
     * fifteen between them, and the buyer is owed that number rather than a
     * refusal two steps later.
     */
    private function floorSum(Campaign $campaign): float
    {
        return (float) $this->briefPlatforms($campaign)
            ->map(fn (Campaign $c): float => $this->platforms->for($c)->minimumDailyBudget())
            ->sum();
    }

    private function viableFloor(Campaign $campaign): float
    {
        $floors = $this->briefPlatforms($campaign)
            ->map(fn (Campaign $c): float => $this->platforms->for($c)->minimumDailyBudget());

        if ($floors->count() < 2) {
            return (float) ($floors->first() ?? $this->platforms->for($campaign)->minimumDailyBudget());
        }

        return (float) $floors->max() * $floors->count();
    }

    /**
     * Enough to be worth running, rather than merely allowed.
     *
     * A campaign at exactly the floor is a campaign that will not deliver, so
     * the recommendation sits a step above it. Doubling is a convention rather
     * than a finding: it puts a single platform at 10.00 and a two-platform
     * brief at 20.00, which are the amounts a buyer setting up a test would
     * reach for anyway.
     */
    private function comfortable(Campaign $campaign, float $floor): float
    {
        return max($floor * 2, 10.0);
    }

    /** Why that one, in the buyer's terms rather than ours. */
    private function whySuggested(Campaign $campaign, float $floor): string
    {
        $platforms = $this->briefPlatforms($campaign);

        if ($platforms->count() < 2) {
            return 'Enough to deliver on '.$this->platforms->for($campaign)->name().'.';
        }

        return sprintf(
            'Split across %d platforms, and above the %s minimum of %s each.',
            $platforms->count(),
            $platforms
                ->sortByDesc(fn (Campaign $c): float => $this->platforms->for($c)->minimumDailyBudget())
                ->map(fn (Campaign $c): string => $this->platforms->for($c)->name())
                ->first(),
            number_format($platforms->map(
                fn (Campaign $c): float => $this->platforms->for($c)->minimumDailyBudget(),
            )->max(), 2),
        );
    }

    /**
     * The campaigns this budget covers, one per platform.
     *
     * @return Collection<int, Campaign>
     */
    private function briefPlatforms(Campaign $campaign): Collection
    {
        // Asked of Briefs, which counts the campaigns the first message made
        // before there was a brief to attach them to.
        //
        // This read the brief's own relation, so on the first turn of a chat it
        // returned one campaign while sharedPlatforms() - which $splitting is
        // decided by, three lines apart - counted two. A 30.00 total across two
        // platforms was then divided by one and refused against the
        // per-campaign ceiling, with the arithmetic quoting "across 1
        // platforms". floorSum() had the mirror of it and waved through a total
        // no split could satisfy. One source now answers both.
        $shared = $this->briefs->sharedCampaigns();

        return $shared->isNotEmpty() ? $shared : collect([$campaign]);
    }

    public function handle(Request $request): Stringable|string
    {
        $campaign = $this->workspace->focus();

        if (! $campaign instanceof Campaign) {
            return $this->json([
                'ok' => false,
                'error' => 'No campaign is in focus. Create one, or switch focus to an existing one, first.',
            ]);
        }

        $arguments = $request->all();

        if ($problem = $this->applyStrategy($campaign, $arguments)) {
            return $problem;
        }

        // The same ceiling the publish gate enforces, read from the one place
        // that knows it. Two sources for one limit is how this tool came to
        // refuse a budget in rupees against a number expressed in dollars,
        // while the gate would have allowed it.
        $ceiling = BudgetCeiling::for($campaign);

        if ($ceiling === null) {
            return $this->json([
                'ok' => false,
                'error' => ucfirst(BudgetCeiling::missingReason($campaign)).'.',
                'note' => 'This needs an administrator, not a different amount. Say so and stop.',
            ]);
        }

        $amount = $arguments['amount'] ?? null;

        // Unlike an objective there is no fixed list, so these are rungs
        // rather than the whole answer: between the platform's floor and the
        // ceiling this product enforces, with typing still open.
        if (blank($amount)) {
            // Both questions at once, so the whole of "how the budget works" is
            // one screen rather than two turns.
            if ($campaign->budget_level === null) {
                $this->offerStrategy($campaign);
            }

            $this->offerAmounts($campaign, $campaign->budgetMode(), $ceiling);

            if ($this->noViableAmount !== null) {
                return $this->json(['ok' => false, 'error' => $this->noViableAmount]);
            }

            return $this->json([
                'ok' => true,
                'note' => 'Amounts are on screen. Say one short line and mention they can type any amount instead.',
            ]);
        }

        $budget = round((float) $amount, 2);
        $lifetime = $campaign->usesLifetimeBudget();

        if ($budget <= 0) {
            return $this->json(['ok' => false, 'error' => 'Budget must be greater than zero.']);
        }

        // With more than one platform the amount is the total, and it belongs
        // to the brief: each campaign then holds its own share, which is what
        // the gate, the ceiling and the platform floor already read. Splitting
        // it is asked separately, never done here and never silently.
        // Counted from what this brief will cover once it has settled, not
        // from what it holds right now. On a first message the campaigns are
        // created before the conversation exists, so brief_id is null until
        // Briefs::settle() back-fills it after the turn and $brief->campaigns()
        // is still empty while this runs. Seen on dev: "advertise <url> on Meta
        // and Google, 20 a day total, split it between them" wrote the whole 20
        // to Meta, which happened to be in focus, and left Google with nothing
        // and split_budget with no total to divide.
        $splitting = $this->briefs->sharedPlatforms() > 1;

        // The ceiling is what one campaign may spend in a day, so it is
        // compared against one campaign's share rather than against the total.
        //
        // This ran above, before anything knew whether the amount was a total,
        // so "30 a day across Meta, Google and LinkedIn" was read as one
        // campaign asking for 30 and refused against a ceiling of 20, while
        // the three shares it describes are 10 each. Seen on dev on 25 Sep,
        // where the buyer worked around it by naming every share by hand.
        //
        // An even division is the most generous a split can be to the largest
        // share, so a total that fails here fails under every division of it.
        // Uneven splits are still checked share by share when they are chosen,
        // which is split_budget's job and where the real numbers exist.
        //
        // A lifetime total is checked against the ceiling by the day it works
        // out to, which the gate cannot do until there is an end date. Left to
        // the gate rather than guessed at here.
        // A total the platforms cannot divide is refused here, with the
        // arithmetic, rather than accepted and failed at the split.
        //
        // Floors are not equal: Google's is nothing and LinkedIn's is ten, so
        // three platforms need fifteen between them. On arb-dev a buyer typed
        // 10, it was taken as the total, and only two turns later at the split
        // did anything work out that it could never be divided. Nothing
        // compared the total against what the platforms would accept.
        $floorSum = $this->floorSum($campaign);

        if ($splitting && ! $lifetime && $budget < $floorSum) {
            return $this->json([
                'ok' => false,
                'error' => sprintf(
                    'These %d platforms need at least %s a day between them (%s), so %s cannot be divided across them. '
                    .'Tell the user the minimum rather than choosing a number yourself.',
                    $this->briefPlatforms($campaign)->count(),
                    number_format($floorSum, 2),
                    $this->briefPlatforms($campaign)
                        ->map(fn (Campaign $c): string => $this->platforms->for($c)->name().' '
                            .number_format($this->platforms->for($c)->minimumDailyBudget(), 2))
                        ->implode(', '),
                    number_format($budget, 2),
                ),
            ]);
        }

        $platforms = max(1, $this->briefPlatforms($campaign)->count());
        $perCampaign = $splitting ? $budget / $platforms : $budget;

        // A large amount is read back before it is set.
        //
        // The ceiling alone is a cliff: everything under it is accepted in
        // silence and the first thing over it is refused outright. A budget is
        // the one field where a mistyped zero spends somebody's money, so
        // between the two there is a band where the amount is repeated back and
        // has to be confirmed. Confirming sends the same amount again with
        // confirmed: true, which is why this checks the flag rather than
        // remembering anything.
        $confirmAbove = (float) config('platforms.guardrails.confirm_daily_budget_above', 0);

        if (! $lifetime
            && $confirmAbove > 0
            && $perCampaign > $confirmAbove
            && $perCampaign <= $ceiling
            && ! ($arguments['confirmed'] ?? false)) {
            return $this->json([
                'ok' => false,
                'needs_confirmation' => true,
                'amount' => number_format($perCampaign, 2),
                'currency' => $campaign->currency ?: 'USD',
                'error' => sprintf(
                    'Confirm before this is set: %s %s a day%s. Read the amount back to the user and ask '
                    .'them to confirm it, then call this again with confirmed true and the same amount.',
                    $campaign->currency ?: 'USD',
                    number_format($perCampaign, 2),
                    $splitting ? sprintf(' for each of %d platforms', $platforms) : '',
                ),
            ]);
        }

        if (! $lifetime && $perCampaign > $ceiling) {
            return $this->json([
                'ok' => false,
                'error' => $splitting
                    ? sprintf(
                        'Split evenly across %d platforms that is %.2f a day each, which is above the %.2f '
                        .'ceiling this product is configured to allow. Tell the user the limit rather than '
                        .'choosing a lower number yourself.',
                        $platforms,
                        $perCampaign,
                        $ceiling,
                    )
                    : sprintf(
                        'A daily budget of %.2f is above the %.2f ceiling this product is configured to allow. '
                        .'Tell the user the limit rather than choosing a lower number yourself.',
                        $budget,
                        $ceiling,
                    ),
            ]);
        }

        if ($splitting) {
            // Held by Briefs when there is no brief row yet, for the same
            // reason and flushed at the same moment as the page analysis.
            $this->briefs->rememberTotal($budget, $campaign->budgetMode());
        } else {
            $campaign->update(['budget' => $budget]);
        }

        // Green on the panel means "you said this". It was recorded green even
        // when the model picked the number itself and nobody had been asked,
        // which is the one thing the dot exists to rule out.
        $this->workspace->recordSource($campaign, 'budget', $this->workspace->wasOffered('budget_amount')
            ? ArtifactFieldSource::USER_STATED
            : ArtifactFieldSource::MODEL_INFERRED);

        return $this->json(array_filter([
            'ok' => true,
            'campaign_id' => $campaign->id,
            'amount' => number_format($budget, 2, '.', ''),
            // Said back so the model asks how to divide it rather than assuming
            // the whole amount applies to each platform.
            'is_total_across_platforms' => $splitting ?: null,
            'next' => $splitting
                ? 'This runs on several platforms, so that is the total. Call split_budget to offer how to divide it.'
                : null,
            'mode' => $campaign->budgetMode(),
            'level' => $campaign->budgetLevel(),
            // Said back so the model does not have to work out that a lifetime
            // budget now needs an end date before it can be published.
            'still_needed' => $lifetime && ! $campaign->ends_at
                ? 'a lifetime budget needs an end date' : null,
        ], fn (mixed $value): bool => $value !== null));
    }

    /**
     * Record how the budget works, if the call said anything about it.
     *
     * Returns an error payload rather than throwing, so an unrecognised value
     * reaches the model as something it can correct.
     */
    private function applyStrategy(Campaign $campaign, array $arguments): ?string
    {
        $changes = [];

        foreach (['mode' => ['daily', 'lifetime'], 'level' => ['adset', 'campaign']] as $key => $allowed) {
            if (blank($arguments[$key] ?? null)) {
                continue;
            }

            $value = strtolower(trim((string) $arguments[$key]));

            if (! in_array($value, $allowed, true)) {
                return $this->json([
                    'ok' => false,
                    'error' => "[{$value}] is not a budget {$key}.",
                    'supported' => $allowed,
                ]);
            }

            $changes['budget_'.$key] = $value;
        }

        if ($changes === []) {
            return null;
        }

        $campaign->update($changes);

        $source = $this->workspace->wasOffered('budget_strategy')
            ? ArtifactFieldSource::USER_STATED
            : ArtifactFieldSource::MODEL_INFERRED;

        foreach (array_keys($changes) as $field) {
            $this->workspace->recordSource($campaign, $field, $source);
        }

        return null;
    }

    private function json(array $payload): string
    {
        return json_encode($payload, JSON_UNESCAPED_SLASHES);
    }
}
