<?php

namespace App\Agent\Tools\Campaign;

use App\Agent\Choices;
use App\Agent\Tools\Campaign\Concerns\ActsOnFocusedCampaign;
use App\Agent\Workspace;
use App\Campaigns\Briefs;
use App\Campaigns\Platforms\PlatformRules;
use App\Campaigns\Platforms\Platforms;
use App\Models\ArtifactFieldSource;
use App\Models\Campaign;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

/**
 * How the campaign in focus bids.
 *
 * A target cost per lead or acquisition is expressed as COST_CAP with an
 * amount; without an amount the platform rejects the ad set.
 */
class SetBidding implements Tool
{
    use ActsOnFocusedCampaign;

    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 bid strategy on the campaign in focus. Pass target_cost when the user '
            .'states a target cost per lead or per acquisition. Call with no argument to put '
            .'the strategies this platform supports on screen.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            // Values are not listed here. Each platform has its own, and this
            // description is written once and sent for every campaign, so
            // naming Meta's told the model to send COST_CAP to Google.
            'strategy' => $schema->string()
                ->description('The chosen bid strategy. The accepted values depend on the platform '
                    .'this campaign runs on, so omit this to have the supported ones listed for '
                    .'the user to pick from rather than guessing them.'),
            'target_cost' => $schema->number()
                ->description('Target cost per result. Required by the strategies that are defined '
                    .'by a number to aim at, and ignored by the rest.'),
        ];
    }

    /**
     * The strategies this platform has, named the way a buyer says them.
     *
     * The constants are unreadable on every platform and the difference between
     * them is the whole decision, so each carries the sentence that makes it
     * one. The list is the platform's: these were Meta's three, hardcoded, so a
     * Google campaign was offered COST_CAP and a bid cap, neither of which
     * Google has.
     */
    private function offerStrategies(PlatformRules $rules): void
    {
        $this->choices->offer(
            field: 'bid_strategy',
            question: 'How should it bid?',
            options: $rules->bidStrategyOptions(),
        );
    }

    /**
     * Target costs worth one click, with typing still open.
     *
     * The buyer knows the number, but being shown a ladder beats being asked an
     * open question with nothing to click: the strategy chips are already gone
     * by this point, so an empty screen is the alternative.
     */
    private function offerTargetCost(string $strategy, string $currency): void
    {
        $this->choices->offer(
            field: 'bid_amount',
            question: $strategy === 'LOWEST_COST_WITH_BID_CAP'
                ? 'What is the most you will pay per result? Pick one or type any amount.'
                : 'What should each result cost on average? Pick one or type any amount.',
            options: array_map(fn (int $amount): array => [
                'value' => (string) $amount,
                'label' => $currency.' '.$amount,
                'sends' => 'Set the target cost to '.$amount,
            ], [1, 2, 5, 10, 25]),
        );
    }

    /**
     * The intents a bid strategy can express, in plain words.
     *
     * Spend the budget as well as you can, hold the cost per result near a
     * number, or never pay more than a number. Every platform sells all three
     * under some name of its own.
     *
     * @var list<string>
     */
    private const INTENTS = ['AUTOMATIC', 'TARGET_COST', 'BID_CAP'];

    /**
     * This platform's word for the intent a sibling campaign already carries.
     *
     * Null when no sibling has chosen yet, when none of the intents produced
     * the sibling's strategy, or when this platform cannot serve it. All three
     * mean the question has to be asked, which is the right outcome: a bid
     * strategy decides how money is spent, and the nearest available match is
     * not an answer.
     *
     * An amount is deliberately not inherited. A cost cap of one dollar on
     * Meta does not mean one dollar is sensible on LinkedIn, whose floor is
     * higher, so the strategy carries across and the number is still asked for.
     */
    private function sharedStrategy(Campaign $campaign, PlatformRules $rules): ?string
    {
        $brief = $this->briefs->current();

        if ($brief === null) {
            return null;
        }

        $sibling = $brief->campaigns()
            ->whereKeyNot($campaign->getKey())
            ->whereNotNull('bid_strategy')
            ->orderBy('id')
            ->first();

        if ($sibling === null) {
            return null;
        }

        $theirs = $this->platforms->for($sibling);

        foreach (self::INTENTS as $intent) {
            if ($theirs->bidStrategyFor($intent, $sibling->objective) !== $sibling->bid_strategy) {
                continue;
            }

            $mine = $rules->bidStrategyFor($intent, $campaign->objective);

            return $mine !== null && in_array($mine, $rules->bidStrategies(), true) ? $mine : null;
        }

        return null;
    }

    public function handle(Request $request): Stringable|string
    {
        if (! $campaign = $this->focused()) {
            return $this->noCampaign();
        }

        $arguments = $request->all();
        $rules = $this->platforms->for($campaign);
        $fromBrief = false;

        $strategy = filled($arguments['strategy'] ?? null)
            ? strtoupper(trim((string) $arguments['strategy']))
            : null;

        $amount = isset($arguments['target_cost']) ? round((float) $arguments['target_cost'], 2) : null;

        // An amount on its own finishes a strategy already chosen, which is what
        // clicking one of the target costs below sends.
        if ($strategy === null && $amount !== null && filled($campaign->bid_strategy)) {
            $strategy = $campaign->bid_strategy;
        }

        // Already answered on another platform, in another vocabulary.
        //
        // "Bid automatically" is LOWEST_COST_WITHOUT_CAP on Meta,
        // MAXIMIZE_CLICKS on Google and AUTOMATED_BID on LinkedIn, so the buyer
        // was asked the same question once per platform and had to recognise
        // their own answer under three names.
        //
        // Read from a sibling campaign rather than from the brief, which has no
        // column for it. The platform that already has a strategy is asked
        // which intent produced it, and this platform is asked for its own word
        // for the same intent, so the two mappings cannot disagree.
        if ($strategy === null && ($shared = $this->sharedStrategy($campaign, $rules))) {
            $strategy = $shared;
            $fromBrief = true;
        }

        if ($strategy === null) {
            $this->offerStrategies($rules);

            return $this->json([
                'ok' => true,
                'note' => 'The strategies are on screen. Say one short line and wait.',
            ]);
        }

        if (! in_array($strategy, $rules->bidStrategies(), true)) {
            return $this->json([
                'ok' => false,
                'error' => sprintf('%s does not support the %s bid strategy.', $rules->name(), $strategy),
                'supported' => $rules->bidStrategies(),
            ]);
        }

        $needsAmount = $rules->bidStrategyNeedsTarget($strategy);

        // The strategy is recorded even without its amount, and the amount is
        // asked for as options rather than prose.
        //
        // Refusing here instead put the conversation in a loop that could not be
        // escaped: nothing was stored, so the flow's next step was still bidding,
        // so the controller re-offered the same three strategies every turn, so
        // clicking "cost per result goal" asked "how should it bid?" again. Seen
        // on arb-dev on 17 Sep, five times in one conversation.
        //
        // Publishing is still blocked until the amount arrives, by PublishGate:
        // "bid strategy needs a target cost".
        if ($needsAmount && $amount === null) {
            $campaign->update(['bid_strategy' => $strategy, 'bid_amount' => null]);

            $this->workspace->recordSource($campaign, 'bid_strategy', ArtifactFieldSource::USER_STATED);

            $this->offerTargetCost($strategy, $campaign->currency ?: 'USD');

            return $this->json(array_filter([
                'ok' => true,
                'campaign_id' => $campaign->id,
                'strategy' => $strategy,
                'from_brief' => $fromBrief ?: null,
                'still_needed' => 'a target cost, which is on screen as options',
                // The amount is not carried across. A one dollar cap that suits
                // Meta may be under another platform's floor entirely.
                'note' => $fromBrief
                    ? 'The strategy is the one already chosen for this brief, in this platform\'s own terms. '
                        .'Say so in one short line, then the amounts are on screen and still need answering.'
                    : 'Amounts are on screen. Say one short line and mention they can type any amount.',
            ], fn (mixed $value): bool => $value !== null));
        }

        $campaign->update([
            'bid_strategy' => $strategy,
            'bid_amount' => $needsAmount ? $amount : null,
        ]);

        $this->workspace->recordSource($campaign, 'bid_strategy', ArtifactFieldSource::USER_STATED);

        if ($needsAmount) {
            $this->workspace->recordSource($campaign, 'bid_amount', $this->workspace->wasOffered('bid_amount')
                ? ArtifactFieldSource::USER_STATED
                : ArtifactFieldSource::MODEL_INFERRED);
        }

        return $this->json(array_filter([
            'ok' => true,
            'campaign_id' => $campaign->id,
            'strategy' => $strategy,
            'target_cost' => $needsAmount ? number_format($amount, 2, '.', '') : null,
            'from_brief' => $fromBrief ?: null,
            'note' => $fromBrief
                ? 'The strategy is the one already chosen for this brief, in this platform\'s own terms. '
                    .'Say so in one short line, mention they can change it, and move on.'
                : null,
        ], fn ($v) => $v !== null));
    }
}
