<?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 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 the campaign budget 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.';
    }

    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;

        if (! $brief instanceof Brief) {
            return $this->json([
                'ok' => false,
                'error' => 'There is no brief here to divide, so there is nothing to split. '
                    .'This only applies when one campaign runs on several platforms.',
            ]);
        }

        $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. '
                    .'Use set_campaign_budget instead.',
            ]);
        }

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

        if ($total <= 0) {
            return $this->json([
                'ok' => false,
                'error' => 'Ask for the total budget first, then divide it.',
            ]);
        }

        $arguments = $request->all();

        // 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'] ?? []);

        // 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);

            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, 2, '.', ''),
                '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);
    }

    /**
     * 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();
        $even = round($total / $count, 2);

        $options = [[
            'value' => 'even',
            'label' => 'Split evenly',
            'hint' => sprintf('%s %s each, across %d platforms.', $currency, number_format($even, 2), $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, 2);

            $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, 2),
                ),
                '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, 2)),
            options: $options,
        );
    }

    /**
     * Shares as platform to amount, from either shape they arrive in.
     *
     * @return array<string, float>
     */
    private function sharesFrom(mixed $given): 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), 2);

                continue;
            }

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

        return $shares;
    }

    /**
     * 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): array
    {
        $platforms = $campaigns->pluck('platform')->all();
        $count = count($platforms);

        if (strtolower($name) === 'even') {
            $each = floor($total / $count * 100) / 100;
            $shares = array_fill_keys($platforms, $each);
            $shares[$platforms[0]] = round($total - $each * ($count - 1), 2);

            return $shares;
        }

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

            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
    {
        $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 %.2f. Give it an amount, or drop the platform',
                    $rules->name(),
                    $share,
                );

                continue;
            }

            if ($share < $floor) {
                $problems[] = sprintf('%s needs at least %.2f a day, not %.2f', $rules->name(), $floor, $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 %.2f is above the %.2f this product allows', $rules->name(), $share, $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) >= 0.01;

        // 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 => number_format((float) $c->budget, 2, '.', ''),
            ])->all(),
            'total' => number_format((float) $allocated, 2, '.', ''),
            // 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 %.2f, not the %.2f named earlier, so the brief now records %.2f. Say so.',
                    $allocated,
                    $total,
                    $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);
    }
}
