<?php

namespace App\Agent\Tools\Campaign;

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

/**
 * How one budget is divided between the platforms it runs on.
 *
 * The brief holds the total the buyer named; each campaign holds its own share,
 * which is what every existing check already reads. Nothing is divided without
 * being asked: an even split is offered as a suggestion, never applied quietly,
 * because money moving on its own is the one thing nobody forgives.
 *
 * Each share is checked against that platform's own floor and the product
 * ceiling before it is accepted, so a split that cannot work is refused where
 * it is proposed rather than at publish, when the user has stopped looking.
 */
class SplitBudget implements Tool
{
    public function __construct(
        private readonly Workspace $workspace,
        private readonly Choices $choices,
        private readonly Platforms $platforms,
    ) {}

    public function description(): Stringable|string
    {
        return 'Divide a budget that has already been set between the platforms in this '
            .'conversation. Call with no arguments to put suggested splits on screen, or pass shares '
            .'as platform to amount, for example {"meta": 30, "google": 20}. Only for conversations '
            .'advertising the same thing in more than one place, and only after set_campaign_budget '
            .'has recorded the total - there is nothing to divide before that.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'split' => $schema->string()
                ->description('A named split: even, or weighted to put most on the platform that can publish.'),
            // A list of pairs rather than a free-form object: a map with
            // arbitrary keys has no declarable shape, and a provider that
            // validates the schema refuses the request outright.
            'shares' => $schema->array()->items($schema->object([
                'platform' => $schema->string()->description('meta, google, tiktok, taboola or linkedin')->required(),
                'amount' => $schema->number()->description('That platform\'s share, in the account currency')->required(),
            ]))->description('The share for each platform. Omit to offer splits instead.'),
        ];
    }

    public function handle(Request $request): Stringable|string
    {
        $focus = $this->workspace->focus();
        $brief = $focus instanceof Campaign ? $focus->brief : null;

        // Each of these three refusals names the tool to call instead.
        //
        // All three were already refusals, and that was the problem: the reply
        // said what was wrong and not what to do, so the model reported the
        // refusal to the buyer as though the conversation had hit a wall. "Ask for
        // the total budget first" in particular reads as an instruction to the
        // user, and the turn ended with a question nobody could usefully answer.
        if (! $brief instanceof Brief) {
            return $this->json([
                'ok' => false,
                'error' => 'There is no brief here to divide, so there is nothing to split.',
                'note' => 'This only applies when one conversation advertises the same thing on '
                    .'several platforms. Use set_campaign_budget for this campaign instead, and do '
                    .'not mention splitting to the user.',
            ]);
        }

        $campaigns = $brief->campaigns()->orderBy('id')->get();

        if ($campaigns->count() < 2) {
            return $this->json([
                'ok' => false,
                'error' => 'This is only running on one platform, so its budget is already whole.',
                'note' => 'Call set_campaign_budget instead. Do not tell the user a split failed; '
                    .'there is nothing to split.',
            ]);
        }

        $total = (float) $brief->budget;

        if ($total <= 0) {
            return $this->json([
                'ok' => false,
                'error' => 'No total has been recorded yet, so there is nothing to divide.',
                'platforms' => $campaigns->pluck('platform')->all(),
                'note' => 'Call set_campaign_budget first with the daily total for all of these '
                    .'platforms together. It will put amounts on screen and say that the figure is a '
                    .'total, and then this can divide it. Ask the user for one total, not one amount '
                    .'per platform.',
            ]);
        }

        $arguments = $request->all();

        // The account's currency decides what a share may be rounded to. Every
        // amount below passes through it, because a division that produces an
        // amount the currency cannot express stops adding up to the total.
        $currency = $brief->currency ?: 'USD';

        // Accepts either the declared list of pairs or a plain platform-to-amount
        // map, because the map is what a clicked answer and our own tests pass.
        $shares = $this->sharesFrom($arguments['shares'] ?? [], $currency);

        // A clicked option sends the name of a split, not the arithmetic. Doing
        // the sums here keeps the numbers the user was shown and the numbers
        // written the same ones.
        if ($shares === [] && filled($arguments['split'] ?? null)) {
            $shares = $this->preset((string) $arguments['split'], $campaigns, $total, $currency);

            if ($shares === []) {
                return $this->json([
                    'ok' => false,
                    'error' => '['.$arguments['split'].'] is not a split I know. Use even or weighted, '
                        .'or name an amount per platform.',
                ]);
            }
        }

        if ($shares === []) {
            $this->offerSplits($brief, $campaigns, $total);

            return $this->json([
                'ok' => true,
                'total' => number_format($total, $this->decimals($currency), '.', ''),
                'platforms' => $campaigns->pluck('platform')->all(),
                'note' => 'The splits are on screen. Say one short line and mention they can name an '
                    .'amount per platform instead.',
            ]);
        }

        return $this->apply($brief, $campaigns, $shares, $total, $currency);
    }

    /**
     * Put the ways of dividing it on screen.
     *
     * Even is suggested because it assumes nothing. Weighting toward the
     * platform that can actually publish is offered as well, since a draft on a
     * platform we cannot reach spends nothing whatever it is allocated.
     */
    private function offerSplits(Brief $brief, $campaigns, float $total): void
    {
        $currency = $brief->currency ?: 'USD';
        $count = $campaigns->count();
        $decimals = $this->decimals($currency);
        $even = round($total / $count, $decimals);

        $options = [[
            'value' => 'even',
            'label' => 'Split evenly',
            'hint' => sprintf('%s %s each, across %d platforms.', $currency, number_format($even, $decimals), $count),
            'badge' => 'Suggested',
            'sends' => 'Split the budget evenly',
        ]];

        // Only worth offering when one platform can publish and another cannot,
        // which is the situation that makes an even split questionable.
        $live = $campaigns->firstWhere('platform', 'meta');

        if ($live && $count > 1) {
            $weighted = round($total * 0.7, $decimals);

            $options[] = [
                'value' => 'weighted',
                'label' => 'Most on '.$this->platforms->for('meta')->name(),
                'hint' => sprintf(
                    '%s %s there, the rest shared. It is the only one that can publish today.',
                    $currency,
                    number_format($weighted, $decimals),
                ),
                'sends' => 'Put most of the budget on Meta',
            ];
        }

        $this->choices->offer(
            field: 'budget_split',
            question: sprintf('How should the %s %s a day be divided?', $currency, number_format($total, $decimals)),
            options: $options,
        );
    }

    /**
     * Shares as platform to amount, from either shape they arrive in.
     *
     * @return array<string, float>
     */
    private function sharesFrom(mixed $given, ?string $currency = null): array
    {
        if (! is_array($given)) {
            return [];
        }

        $shares = [];

        foreach ($given as $key => $value) {
            // The declared shape: a list of {platform, amount} pairs.
            if (is_array($value) && isset($value['platform'])) {
                $shares[strtolower(trim((string) $value['platform']))] = round(
                    (float) ($value['amount'] ?? 0),
                    $this->decimals($currency),
                );

                continue;
            }

            // The plain map, which is what a clicked answer sends.
            if (is_string($key)) {
                $shares[strtolower(trim($key))] = round((float) $value, $this->decimals($currency));
            }
        }

        return $shares;
    }

    /**
     * How many decimals a share of this currency may carry.
     *
     * The currency's own exponent, clamped by what the column holds. `budget` is
     * decimal(12,2) with a decimal:2 cast on both Campaign and Brief, so a third
     * decimal is discarded on write: rounding a Kuwaiti dinar share to 33.333
     * stores 33.33, and three of those add up to 99.99 rather than 100. Dividing
     * to a precision the database cannot keep breaks the one thing this tool
     * guarantees, so it is capped here rather than pretended.
     *
     * The zero-decimal currencies are the correction that matters, and they are
     * below the cap: a yen share is a whole yen, where a hardcoded factor of 100
     * handed out 333.33 and toMinor() then rounded it to 333.
     *
     * Widening the column to decimal(12,3) is the real fix for the dinar, and it
     * belongs with the rest of the money path rather than in a budget splitter.
     */
    private function decimals(?string $currency): int
    {
        return min(CurrencyMinorUnits::decimals($currency), 2);
    }

    /**
     * The arithmetic behind a named split.
     *
     * The remainder goes to the first platform rather than being spread, so the
     * shares always add back up to the total the buyer named. A penny is not
     * worth an explanation; a total that does not reconcile is.
     *
     * @return array<string, float>
     */
    private function preset(string $name, $campaigns, float $total, ?string $currency = null): array
    {
        $platforms = $campaigns->pluck('platform')->all();
        $count = count($platforms);

        $unit = 10 ** $this->decimals($currency);

        if (strtolower($name) === 'even') {
            $each = floor($total / $count * $unit) / $unit;
            $shares = array_fill_keys($platforms, $each);
            $shares[$platforms[0]] = round($total - $each * ($count - 1), $this->decimals($currency));

            return $shares;
        }

        if (strtolower($name) === 'weighted' && in_array('meta', $platforms, true)) {
            $rest = $count - 1;
            $each = $rest > 0 ? floor($total * 0.3 * $unit / $rest) / $unit : 0.0;
            $shares = array_fill_keys($platforms, $each);
            $shares['meta'] = round($total - $each * $rest, $this->decimals($currency));

            return $shares;
        }

        return [];
    }

    /**
     * Write each platform's share, or refuse the whole split.
     *
     * All or nothing on purpose: half-applying a division leaves the money in a
     * state nobody chose, and the totals no longer add up to what was agreed.
     *
     * @param  array<string, mixed>  $shares
     */
    private function apply(Brief $brief, $campaigns, array $shares, float $total, ?string $currency = null): string
    {
        $decimals = $this->decimals($currency);
        $money = fn (float $amount): string => number_format($amount, $decimals, '.', '');

        $named = collect($shares);

        $missing = $campaigns->pluck('platform')->diff($named->keys())->values();

        if ($missing->isNotEmpty()) {
            return $this->json([
                'ok' => false,
                'error' => 'No amount was given for '.$missing->implode(', ').'. Name every platform, '
                    .'or leave the shares out to see the suggested splits.',
            ]);
        }

        $problems = [];

        foreach ($campaigns as $campaign) {
            $share = $named[$campaign->platform];
            $rules = $this->platforms->for($campaign);
            $floor = $rules->minimumDailyBudget();

            // Checked on its own, not left to the floor.
            //
            // A share of zero used to be caught by accident, because every
            // platform claimed a 5.00 minimum. Google's real minimum is none,
            // so once that invention was removed a split of "10 and 0" would
            // have been accepted and created a campaign with no money in it.
            // Not running on a platform is an answer, but it is removing the
            // campaign rather than funding it with nothing.
            if ($share <= 0) {
                $problems[] = sprintf(
                    '%s cannot have a share of %s. Give it an amount, or drop the platform',
                    $rules->name(),
                    $money($share),
                );

                continue;
            }

            if ($share < $floor) {
                $problems[] = sprintf('%s needs at least %s a day, not %s', $rules->name(), $money($floor), $money($share));

                continue;
            }

            // Read per campaign: the ceiling follows the account's currency, and
            // two platforms under one brief can bill in different ones.
            $ceiling = BudgetCeiling::for($campaign);

            if ($ceiling !== null && $share > $ceiling) {
                $problems[] = sprintf('%s at %s is above the %s this product allows', $rules->name(), $money($share), $money($ceiling));
            }
        }

        if ($problems !== []) {
            return $this->json([
                'ok' => false,
                'error' => 'That split cannot run: '.implode('; ', $problems).'.',
                'note' => 'Tell the user which platform is the problem and why. Do not pick different '
                    .'numbers on their behalf.',
            ]);
        }

        foreach ($campaigns as $campaign) {
            $campaign->update(['budget' => $named[$campaign->platform]]);
            $this->workspace->recordSource($campaign, 'budget', ArtifactFieldSource::USER_STATED);
        }

        $allocated = $named->only($campaigns->pluck('platform')->all())->sum();
        $restated = abs($allocated - $total) >= 1 / (2 * 10 ** $decimals);

        // Naming every share is naming the total, so the brief is brought with
        // them rather than left behind.
        //
        // It used to keep whatever total had been recorded before, and
        // PublishGate refuses a brief whose shares exceed its total, so
        // "10 and 10 and 10" against a recorded total of 10 was accepted here,
        // reported as a discrepancy, and then blocked at publish with nothing
        // the buyer could do about it: no tool asks for the total again, and the
        // shares they had just named were each perfectly valid. Seen on arb-dev
        // on 25 Sep, where the chat said "platform shares total USD 30/day, but
        // the brief's recorded total is USD 10/day" and offered no way out.
        //
        // Safe because it is a record and not a limit. Every share has already
        // been measured against its platform's floor and its account's ceiling
        // above; the brief's total is what the buyer said they would spend, and
        // this is them saying it differently.
        if ($restated) {
            $brief->update(['budget' => $allocated]);
        }

        return $this->json(array_filter([
            'ok' => true,
            'shares' => $campaigns->mapWithKeys(fn (Campaign $c): array => [
                $c->platform => $money((float) $c->budget),
            ])->all(),
            'total' => $money((float) $allocated),
            // Said rather than silently corrected: the buyer may have meant it,
            // and quietly changing their arithmetic is worse than mentioning it.
            'note' => $restated
                ? sprintf(
                    'These add up to %s, not the %s named earlier, so the brief now records %s. Say so.',
                    $money($allocated),
                    $money($total),
                    $money($allocated),
                )
                : null,
        ], fn (mixed $value): bool => $value !== null));
    }

    /** @param  array<string,mixed>  $payload */
    private function json(array $payload): string
    {
        return json_encode($payload, JSON_UNESCAPED_SLASHES);
    }
}
