<?php

namespace App\Agent\Tools\Campaign;

use App\Agent\Choices;
use App\Agent\Workspace;
use App\Campaigns\Briefs;
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;

/**
 * Create a campaign in this conversation.
 *
 * The worked example for the pattern every module should follow:
 *
 *   1. a plain Tool, no knowledge of the agent or the registry
 *   2. the Workspace injected, so the tool never has to be told the context
 *   3. produced() to link and focus the new artifact in one step
 *   4. recordSource() for every field, so the publish gate can tell a stated
 *      value from a guessed one later
 *
 * A campaign row is created only here, never implicitly when a chat starts.
 * A conversation is a workspace, not a campaign.
 */
class StartCampaign implements Tool
{
    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 'Create the campaigns for this conversation, one per platform the user picked, '
            .'and put the first in focus. Pass several platforms when the user wants to advertise '
            .'the same thing in more than one place: they share one brief, so the questions that do '
            .'not change by platform are only asked once. Everything is created as a draft; nothing '
            .'reaches an advertising platform until the user approves it separately. '
            .'Only pass a platform the user has actually named. If they have not said where the ads '
            .'should run, omit it and they will be asked: do not assume Meta, and do not infer a '
            .'platform from the product, the landing page or what happens to be connected.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'name' => $schema->string()
                ->description('A short descriptive campaign name. Optional: one is generated if omitted, and can be renamed later.'),
            // items() is not optional: Gemini rejects the whole request with a
            // 400 when an array parameter does not say what it contains, and
            // the tool schema is sent on every turn, so one bad declaration
            // stops the entire chat rather than just this tool.
            'platforms' => $schema->array()->items($schema->string())
                ->description('The platforms the user named, for example ["meta","google"]. Omit whenever they '
                    .'have not said where the ads should run, which puts the choice on screen for them to pick.'),
            'platform' => $schema->string()
                ->description('A single platform, when the user named exactly one. Same as passing one entry in platforms.'),
            'objective' => $schema->string()
                ->description('The campaign objective, if the user has stated or implied one.'),
        ];
    }

    /**
     * A name that reads as a placeholder, so nobody mistakes it for a decision.
     *
     * The landing page is a better source than the clock when we have read one,
     * and the platform is appended so several campaigns from one brief are told
     * apart in a list.
     */
    private function nameFor(?string $given, string $platform, ?Brief $brief): string
    {
        $given = trim((string) $given);

        if ($given !== '') {
            return $given;
        }

        $fromPage = trim((string) ($brief?->pageAnalysis()?->campaignName() ?? ''));

        return $fromPage !== ''
            ? $fromPage.' - '.$this->platforms->for($platform)->name()
            : ucfirst($platform).' campaign '.now()->format('j M H:i');
    }

    /**
     * The objective, but only if this platform has it.
     *
     * It was written straight to the column with no check, so a Meta objective
     * landed on a Google or LinkedIn campaign: nine refusals across the stored
     * conversations, all of them "[OUTCOME_TRAFFIC] is not supported on Google
     * Ads" arriving a step or two later. Worse than the refusal is what it did
     * to the flow - LaunchFlow::done() only asks whether the field is filled,
     * so the objective step was marked answered and never re-asked, and for
     * Google that also dropped the pixel and conversion event steps, which are
     * gated on an objective the platform does not have. The campaign was then
     * built to the end and refused at the gate.
     *
     * Dropped rather than mapped. Guessing that a Meta traffic objective means
     * Google's WEBSITE_TRAFFIC is a decision about someone's money, and leaving
     * it unset asks the buyer instead, which is the step that already exists.
     */
    private function objectiveForPlatform(?string $objective, string $platform): ?string
    {
        if (blank($objective)) {
            return null;
        }

        $objective = strtoupper(trim((string) $objective));
        $supported = app(Platforms::class)->for($platform)->objectives();

        $supported = array_map(
            fn (mixed $entry): string => strtoupper((string) (is_array($entry) ? ($entry['value'] ?? '') : $entry)),
            $supported,
        );

        return in_array($objective, $supported, true) ? $objective : null;
    }

    public function handle(Request $request): Stringable|string
    {
        // Read through all(): the Request's offsetGet raises on a missing key,
        // and optional arguments are simply absent rather than null.
        $arguments = $request->all();
        $objective = $arguments['objective'] ?? null;

        $chosen = $this->platformsFrom($arguments);

        // The first question of all, and it was the last one still asked in
        // prose. Nothing is in focus yet, so the launch flow cannot offer it:
        // it has to be offered here or not at all.
        if ($chosen === []) {
            $this->offerPlatforms();

            return $this->json([
                'ok' => true,
                'note' => 'The platforms are on screen for the user to pick, and more than one can be '
                    .'chosen. Say one short line and wait. Do not ask for a campaign name: one is generated.',
            ]);
        }

        $supported = $this->platforms->keys();
        $unknown = array_values(array_diff($chosen, $supported));

        if ($unknown !== []) {
            return $this->json([
                'ok' => false,
                'error' => '['.implode(', ', $unknown).'] is not a platform this product supports.',
                'supported' => $supported,
            ]);
        }

        // A brief covers the campaigns made together from it, and only those.
        //
        // Starting a second, unrelated campaign later in the same conversation
        // is a different thing being advertised, so it gets no brief rather
        // than joining this one. Without that rule its budget would be read as
        // a share of the first campaign's total, and two campaigns that have
        // nothing to do with each other would spend the same money.
        $brief = $this->briefs->current();

        // Two different reasons for having no brief, and they need opposite
        // treatment below. Withheld means this campaign must never join one;
        // absent means there is no conversation yet and it must join as soon as
        // there is. Both left $brief null and the orphan record that follows
        // could not tell them apart, so a second unrelated campaign was handed
        // to settle() and adopted into the first one's brief anyway, inheriting
        // its landing page, its dates and a share of its budget. The rule the
        // comment above describes was being undone sixty lines later.
        $withheld = $brief !== null && $brief->campaigns()->exists();

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

        $existing = $this->workspace->isBound()
            ? $this->workspace->artifacts(Campaign::class)->map(fn ($l) => $l->artifactable)->filter()
            : collect();

        $created = [];
        $untouched = [];

        foreach ($chosen as $platform) {
            $campaign = $existing->firstWhere('platform', $platform);

            // A campaign that already exists on the platform is left alone.
            //
            // This branch exists so re-entering the flow updates the draft in
            // hand rather than making a second one. But it ran for a live
            // campaign too, and a buyer asking to start a new campaign got the
            // published one edited instead: Mohsin typed "create campaing" on
            // 28 Sep and campaign 120250886412960429, already on Meta, was
            // renamed and then renamed back. Nothing reached Meta, so our name
            // and Meta's silently disagreed for the length of one turn, and a
            // push of pending changes in between would have made it permanent.
            //
            // Refused rather than made to work: creating a second campaign on
            // the same platform in one conversation is a real gap, and quietly
            // editing the live one is not a smaller version of it.
            if ($campaign?->isPublished()) {
                $untouched[] = [
                    'platform' => $platform,
                    'campaign_id' => $campaign->id,
                    'external_campaign_id' => $campaign->external_campaign_id,
                    'name' => $campaign->name,
                ];

                continue;
            }

            if ($campaign) {
                $platformObjective = $this->objectiveForPlatform($objective, $platform);

                if (filled($platformObjective) && $campaign->objective !== $platformObjective) {
                    $campaign->update(['objective' => $platformObjective]);
                    $this->workspace->recordSource($campaign, 'objective', ArtifactFieldSource::MODEL_INFERRED);
                }

                if (filled($arguments['name'] ?? null) && $campaign->name !== (string) $arguments['name']) {
                    $campaign->update(['name' => (string) $arguments['name']]);
                    $this->workspace->recordSource($campaign, 'name', ArtifactFieldSource::USER_STATED);
                }
            } else {
                $campaign = Campaign::query()->create([
                    'user_id' => auth()->id(),
                    'brief_id' => $brief?->id,
                    // Generated when not given. Asking someone to name a campaign
                    // before it exists is a question about our data model, not about
                    // their advertising, and it can be renamed at any point.
                    'name' => $this->nameFor($arguments['name'] ?? null, $platform, $brief),
                    'platform' => $platform,
                    'objective' => $this->objectiveForPlatform($objective, $platform),
                    // Set here when the platform runs on one account, so it is
                    // never a question. Null for Meta, where it genuinely is one.
                    'ad_account_id' => $this->platforms->for($platform)->defaultAdAccountId(),
                    // Click tracking is not a decision anybody wants to make. Every
                    // campaign should carry it, the standard tagging is right almost
                    // always, and asking cost a question and an answer to arrive at
                    // the value that was going to be used anyway. Set from the
                    // platform's own macros; the buyer is told and can change it.
                    'utm' => $this->platforms->for($platform)->trackingOptions()[0]['value'] ?? null,
                    'status' => 'draft',
                ]);

                // The user named the campaign; the objective and the platform are the
                // model's reading unless they said so outright, so both are marked as
                // inferred and will need confirming before anything spends.
                $this->workspace->recordSource(
                    $campaign,
                    'name',
                    filled($arguments['name'] ?? null) ? ArtifactFieldSource::USER_STATED : ArtifactFieldSource::FALLBACK,
                );

                // Not USER_STATED. This used to claim the buyer picked the platform
                // whatever happened, and nothing here can tell a pick from a guess:
                // choosing from the chooser arrives as an ordinary message, exactly
                // like the model deciding on its own. Seen on dev on 23 Sep, in a
                // chat where Meta was never mentioned once: the campaign was created
                // on Meta and the provenance told the agent the user had asked for
                // it, so nothing ever questioned it. Recording it as the model's own
                // reading is the truthful half; the description below is what stops
                // the guess being made in the first place.
                $this->workspace->recordSource($campaign, 'platform', ArtifactFieldSource::MODEL_INFERRED);

                if (filled($campaign->ad_account_id)) {
                    $this->workspace->recordSource($campaign, 'ad_account_id', ArtifactFieldSource::PLATFORM_RETURNED);
                }

                if (filled($campaign->utm)) {
                    $this->workspace->recordSource($campaign, 'utm', ArtifactFieldSource::PLATFORM_RETURNED);
                }

                if (filled($this->objectiveForPlatform($objective, $platform))) {
                    $this->workspace->recordSource($campaign, 'objective', ArtifactFieldSource::MODEL_INFERRED);
                }

                // No brief to join yet, which on the first message of a new chat
                // means the conversation does not exist rather than that this
                // campaign should not have one. Remembered so Briefs::settle() can
                // attach it once the conversation is there.
                //
                // Not when the brief was withheld: that campaign is meant to
                // stand alone, and remembering it here is how it was adopted
                // anyway.
                if ($brief === null && ! $withheld) {
                    $this->briefs->orphaned($campaign);
                }
            }

            // The first is what the user is talking about; produced() focuses
            // whatever it is given, so the later ones are linked without
            // stealing focus from it.
            $this->workspace->produced($campaign, $request->toolCallId(), focus: $created === []);
            $created[] = $campaign;
        }

        // Nothing made, because every platform asked for already has a live
        // campaign in this conversation. Said plainly, with what exists named,
        // rather than reporting a success that created nothing.
        if ($created === []) {
            return $this->json([
                'ok' => false,
                'error' => sprintf(
                    'This conversation already has a published campaign on %s, and a second campaign on '
                    .'the same platform in one conversation is not supported yet. Nothing was changed.',
                    implode(' and ', array_column($untouched, 'platform')),
                ),
                'existing' => $untouched,
                'note' => 'Tell the user plainly that the existing campaign was left exactly as it is, '
                    .'name it, and offer to start a new chat for a second campaign. Do not rename or '
                    .'edit the published one.',
            ]);
        }

        $first = $created[0];

        return $this->json(array_filter([
            'ok' => true,
            // Named so the model does not report them as newly created.
            'left_untouched' => $untouched ?: null,
            'campaign_id' => $first->id,
            'name' => $first->name,
            'platform' => $first->platform,
            'status' => $first->status,
            'now_in_focus' => true,
            'campaigns' => array_map(fn (Campaign $c): array => [
                'campaign_id' => $c->id,
                'platform' => $c->platform,
                'name' => $c->name,
            ], $created),
            // Said back so the model tells the user the shared questions are
            // coming once rather than once per platform.
            'note' => count($created) > 1
                ? 'These share one brief, so the landing page, budget, dates and creative are asked once for all of them.'
                : null,
        ], fn (mixed $value): bool => $value !== null));
    }

    /**
     * The platforms asked for, however they were given.
     *
     * A list when several were picked, a single string when the model used the
     * older argument, and empty when nothing was chosen yet.
     *
     * @param  array<string, mixed>  $arguments
     * @return list<string>
     */
    private function platformsFrom(array $arguments): array
    {
        $given = $arguments['platforms'] ?? $arguments['platform'] ?? [];

        return collect(is_array($given) ? $given : [$given])
            ->map(fn (mixed $platform): string => strtolower(trim((string) $platform)))
            ->filter()
            // Picking the same platform twice is one campaign, not two.
            ->unique()
            ->values()
            ->all();
    }

    private function offerPlatforms(): void
    {
        // Asked of the platform rather than matched on its name here. This was
        // a copy of the same knowledge the prompt needed, and the two disagreed:
        // LinkedIn counted as available because the MCP flag defaulted on, so a
        // buyer with no LinkedIn credentials was shown it badged "Connected".
        $isAvailable = fn (string $key): bool => $this->platforms->for($key)->isConfigured();

        $keys = array_keys((array) config('platforms.rules'));

        // Prioritize Meta, Google Ads, LinkedIn on the top line, followed by other platforms
        $topPriority = ['meta', 'google', 'linkedin'];
        usort($keys, function (string $a, string $b) use ($topPriority, $isAvailable): int {
            $aIndex = array_search($a, $topPriority, true);
            $bIndex = array_search($b, $topPriority, true);
            $aPriority = $aIndex !== false ? $aIndex : 999;
            $bPriority = $bIndex !== false ? $bIndex : 999;

            if ($aPriority !== $bPriority) {
                return $aPriority <=> $bPriority;
            }

            return ($isAvailable($b) ? 1 : 0) <=> ($isAvailable($a) ? 1 : 0);
        });

        $this->choices->offer(
            field: 'platforms',
            question: 'Where should this run?',
            options: array_map(fn (string $key): array => [
                'value' => $key,
                'label' => $this->platforms->for($key)->name(),
                'hint' => $isAvailable($key) ? 'Ready to publish' : 'Draft only, not connected yet',
                'badge' => $isAvailable($key) ? 'Connected' : null,
                'sends' => 'Advertise on '.$this->platforms->for($key)->name(),
            ], $keys),
            // More than one, because the whole point of the brief is that the
            // same thing can run in several places without being set up twice.
            multiple: true,
            allowOther: false,
        );
    }

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