<?php

namespace App\Agent\Tools\Campaign;

use App\Agent\Tools\Campaign\Concerns\ActsOnFocusedCampaign;
use App\Agent\Workspace;
use App\Campaigns\Objectives;
use App\Campaigns\Platforms\GoogleRules;
use App\Campaigns\Platforms\Platforms;
use App\Models\ArtifactFieldSource;
use App\Models\Asset;
use App\Models\Campaign;
use App\Models\CampaignAd;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Collection;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

/**
 * Write the ads for every campaign on this brief.
 *
 * Replaces the existing set rather than appending, so re-running after an edit
 * does not quietly double the number of ads. Assets are paired round robin, so
 * three copy variations over one image still ship three ads that differ by copy.
 *
 * Written once for all of them, not just the one in focus. Copy is the last
 * thing a campaign needs and the only shared answer that was still per campaign,
 * so on a two-platform brief every "write the ad copy" landed on whichever tab
 * the buyer happened to be looking at. Seen on dev on 23 Sep: a Meta and Google
 * conversation where Google got three ads and Meta finished with none, and the
 * chat called it ready.
 *
 * The platforms do not accept the same text, and for a while every platform got
 * the same text anyway. Meta allows a 40 character headline, Google 30,
 * LinkedIn 70; the body is 125, 90 and 600. One set of copy for the brief had
 * to clear all of them at once, so a three-platform brief was written to
 * Google's 30 and 90 and the other two were handed copy two thirds shorter than
 * they would take. It published, and it wasted most of the room Meta and
 * LinkedIn give you.
 *
 * So a variation can name its platform and is then held to that platform's
 * limits alone. A variation with no platform is still shared, still has to fit
 * the tightest of the campaigns receiving it, and is still the right answer for
 * a brief on one platform or for copy that genuinely reads the same everywhere.
 * Each platform's own limits come back on every call, so the next one can use
 * the room rather than guess at it.
 */
class WriteAdCopy implements Tool
{
    use ActsOnFocusedCampaign;

    public function __construct(
        private readonly Workspace $workspace,
        private readonly Platforms $platforms,
    ) {}

    public function description(): Stringable|string
    {
        return 'Write one or more ad variations for every campaign in this conversation that '
            .'shares a brief, not only the one in focus. Replaces any existing ads. Write the '
            .'copy yourself rather than asking the user for it. Set platform on a variation to '
            .'write copy for that platform alone, which is worth doing whenever they have '
            .'different limits: a variation with no platform goes everywhere and must fit the '
            .'tightest of them. Copy over a limit is refused rather than truncated, and the '
            .'result reports every platform\'s limits.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'ads' => $schema->array()->items(
                $schema->object([
                    // A flat list with the platform on each entry, rather than
                    // an object keyed by platform. The keys would have to be
                    // enumerated from config to be declarable, and Gemini
                    // rejects the whole request over one loose array schema, so
                    // the shape that cannot go wrong is the one worth having.
                    'platform' => $schema->string()->description(
                        'Which platform this variation is for, for example "meta" or "google". '
                        .'Omit to use the same variation on every platform in this conversation, '
                        .'which then has to fit the tightest of their limits.'
                    ),
                    'headline' => $schema->string()->description('Short headline. The limit differs by platform.')->required(),
                    'primary_text' => $schema->string()->description('The main body copy.')->required(),
                    'description' => $schema->string()->description('Optional supporting line.'),
                    'cta' => $schema->string()->description('One of '.implode(', ', Objectives::CALLS_TO_ACTION).'.')->required(),
                ])
            )->description('The ad variations to write.')->required(),
        ];
    }

    /**
     * Whether the arguments carry JSON structure inside their values.
     *
     * A headline is prose. It does not contain `},{`, `}]}` or a bare
     * `field_name:` fragment, and a call to action is a single enum word. When
     * those appear in a value the payload was mangled on the way in, and every
     * field in it is suspect, not just the one that happened to be checked
     * first.
     *
     * @param  list<array<string,mixed>>  $ads
     * @return ?string what was found, for the refusal to quote back
     */
    private function structurallyCorrupt(array $ads): ?string
    {
        foreach ($ads as $index => $ad) {
            if (! is_array($ad)) {
                return sprintf('Ad %d is not an object.', $index + 1);
            }

            foreach (['headline', 'primary_text', 'description', 'cta'] as $field) {
                $value = $ad[$field] ?? null;

                if (! is_string($value)) {
                    continue;
                }

                $fragment = preg_match('/\}\s*,\s*\{|\}\s*\]|\[\s*\{|^\s*,\s*\w+\s*:\s*$|\w+"\s*:/', $value) === 1;

                if ($fragment) {
                    return sprintf(
                        'Ad %d field [%s] contains JSON punctuation: %s.',
                        $index + 1,
                        $field,
                        json_encode(mb_substr($value, 0, 60)),
                    );
                }
            }
        }

        return null;
    }

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

        $ads = (array) ($request->all()['ads'] ?? []);

        if ($ads === []) {
            return $this->json(['ok' => false, 'error' => 'At least one ad variation is required.']);
        }

        // Counted per platform, because that is what the cap means.
        //
        // PublishGate reads it off one campaign's own ads, and this counted the
        // whole submission, so three platforms asking for three variations each
        // came to nine against a limit of five and was refused. The feature
        // exists to write copy per platform and could not be used at three
        // platforms at all: on arb-dev it answered "this campaign group allows
        // five variations total, not nine platform-specific variations" and the
        // buyer fell back to one shared set squeezed to Google's limits.
        //
        // Five ads on one campaign is the thing worth stopping. Five across
        // three campaigns is not the same number and never was.
        $limit = (int) config('platforms.guardrails.max_ads_per_publish');

        $perPlatform = collect($ads)->countBy(fn (array $ad): string => strtolower(trim((string) ($ad['platform'] ?? 'shared'))));

        foreach ($perPlatform as $platform => $count) {
            if ($count > $limit) {
                return $this->json([
                    'ok' => false,
                    'error' => $platform === 'shared'
                        ? sprintf('%d shared variations given, but at most %d may be published to one campaign.', $count, $limit)
                        : sprintf('%d variations given for %s, but at most %d may be published to one campaign.', $count, $platform, $limit),
                ]);
            }
        }

        // Arguments that arrived as broken JSON are named as such.
        //
        // Real payloads from the stored conversations:
        //
        //   "primary_text": "Block pop-ups and annoying ads daily.},{cta:"
        //   "cta": "}]}xsd}"
        //   "headline": ",primary_text:"
        //
        // The model emitted its own delimiters inside the values. This then
        // refused with "[}]}] is not a supported call to action", which sends
        // the model looking for a better call to action when the whole payload
        // is malformed, and it retried a dozen times in one turn, every time.
        // Naming the real problem is what lets it recover on the next attempt
        // rather than the fifteenth.
        if ($corrupt = $this->structurallyCorrupt($ads)) {
            return $this->json([
                'ok' => false,
                'error' => 'These ad arguments did not arrive as valid JSON. '.$corrupt
                    .' Send the ads again as a clean JSON array, with no braces, brackets or field '
                    .'names inside any text value.',
                'note' => 'Do not change the wording to try to fix this and do not retry more than '
                    .'once. If it happens again, write the copy out in the chat and ask the user to '
                    .'confirm it instead.',
            ]);
        }

        foreach ($ads as $index => $ad) {
            foreach (['headline', 'primary_text', 'cta'] as $field) {
                if (blank($ad[$field] ?? null)) {
                    return $this->json(['ok' => false, 'error' => 'Ad '.($index + 1)." is missing {$field}."]);
                }
            }

            if (! in_array(strtoupper((string) $ad['cta']), Objectives::CALLS_TO_ACTION, true)) {
                return $this->json([
                    'ok' => false,
                    'error' => "[{$ad['cta']}] is not a supported call to action.",
                    'supported' => Objectives::CALLS_TO_ACTION,
                ]);
            }
        }

        $targets = $this->sharingTheBrief($campaign);

        // Split by platform before anything is checked, because what counts as
        // too long and how many variations are needed are both the platform's
        // answer, not the brief's.
        $shared = array_values(array_filter($ads, fn (array $ad): bool => blank($ad['platform'] ?? null)));

        $byPlatform = [];

        foreach ($ads as $ad) {
            if (filled($key = strtolower(trim((string) ($ad['platform'] ?? ''))))) {
                $byPlatform[$key][] = $ad;
            }
        }

        $unknown = array_diff(array_keys($byPlatform), $targets->pluck('platform')->all());

        if ($unknown !== []) {
            return $this->json([
                'ok' => false,
                'error' => '['.implode(', ', $unknown).'] is not a platform in this conversation.',
                'platforms' => $targets->pluck('platform')->values()->all(),
            ]);
        }

        // Shared copy still has to clear the tightest limit of every campaign
        // that will receive it. Written per platform it only has to clear that
        // one, which is the whole point: 30 characters is Google's answer and
        // was being imposed on Meta's 40 and LinkedIn's 70.
        $sharedTargets = $targets->reject(fn (Campaign $t): bool => isset($byPlatform[$t->platform]));

        foreach ($targets as $target) {
            $group = $byPlatform[$target->platform] ?? $shared;

            if ($group === []) {
                return $this->json([
                    'ok' => false,
                    'error' => sprintf('No copy was written for %s.', $target->name),
                    'note' => 'Every campaign on this brief needs copy. Write variations for the missing '
                        .'platform, or leave the platform off a variation so it is used everywhere.',
                ]);
            }

            $limits = isset($byPlatform[$target->platform])
                ? $this->platforms->for($target)->copyLimits()
                : $this->tightestLimits($sharedTargets);

            // Google builds one responsive search ad out of the headlines of
            // every ad on the campaign, and refuses it below three. One
            // variation is a perfectly good Meta campaign and an unpublishable
            // Google one, and the refusal arrives at publish, after the
            // campaign and budget exist.
            if (($least = $this->leastVariations(collect([$target]))) > count($group)) {
                return $this->json([
                    'ok' => false,
                    'error' => sprintf(
                        '%d variation%s given for %s. A Google Search ad is built from the headlines of '
                        .'all of them and needs at least %d different ones.',
                        count($group),
                        count($group) === 1 ? '' : 's',
                        $this->platforms->for($target)->name(),
                        $least,
                    ),
                    'note' => 'Write the missing variations yourself and call this again with all of them.',
                ]);
            }

            if ($tooLong = $this->overLimit($group, $limits)) {
                return $this->json([
                    'ok' => false,
                    'error' => sprintf('%s: %s', $this->platforms->for($target)->name(), $tooLong),
                    'limits' => $limits,
                    'note' => 'Rewrite that variation shorter, or give this platform its own variations by '
                        .'setting platform on them. Do not ask the user to shorten it, and do not send it '
                        .'again unchanged: the platform refuses it rather than trimming it.',
                ]);
            }
        }

        // Assets already attached to this conversation, paired round robin.
        $assets = $this->workspace->artifacts(Asset::class)
            ->map(fn ($link) => $link->artifactable)
            ->filter()
            ->values();

        $written = [];

        foreach ($targets as $target) {
            // Remember the pairing before replacing the rows, so rewriting copy
            // does not silently drop creatives the user already chose. Per
            // campaign, because each has its own creatives attached.
            $previous = $target->ads()->orderBy('id')->pluck('asset_id')->all();

            $target->ads()->delete();

            foreach ($byPlatform[$target->platform] ?? $shared as $index => $ad) {
                CampaignAd::create([
                    'campaign_id' => $target->id,
                    'asset_id' => $previous[$index]
                        ?? ($assets->isNotEmpty() ? $assets[$index % $assets->count()]->id : null),
                    'headline' => trim((string) $ad['headline']),
                    'primary_text' => trim((string) $ad['primary_text']),
                    'description' => filled($ad['description'] ?? null) ? trim((string) $ad['description']) : null,
                    'cta' => strtoupper((string) $ad['cta']),
                ]);
            }

            $this->workspace->recordSource($target, 'ads', ArtifactFieldSource::MODEL_INFERRED);

            $written[$target->name] = [
                'platform' => $target->platform,
                'variations' => $target->ads()->count(),
                'own_copy' => isset($byPlatform[$target->platform]),
            ];
        }

        $tailored = collect($written)->where('own_copy', true)->count();

        return $this->json(array_filter([
            'ok' => true,
            'campaign_id' => $campaign->id,
            'written' => $written,
            // Each platform's own room, not the tightest of them. Reported so
            // the next call can use it: a model told only the shared limit has
            // no way to know Meta would have taken ten more characters.
            'limits_by_platform' => $targets
                ->mapWithKeys(fn (Campaign $t): array => [$t->platform => $this->platforms->for($t)->copyLimits()])
                ->all(),
            'assets_attached' => $assets->count(),
            // How much room the shared copy is leaving unused, in characters.
            //
            // Telling the model it "could" tailor the copy was not enough: on
            // arb-dev on 25 Sep it was asked outright to give Meta longer
            // headlines and moved to the next step instead, leaving both
            // platforms on the same 18 to 28 characters against Meta's 40.
            // A rule it has to remember loses to the step in front of it, so
            // the cost of not tailoring is measured here and put in the result,
            // where it is a fact about this copy rather than advice.
            'unused_room' => $tailored === count($written) ? null : $this->unusedRoom($sharedTargets),
            'note' => match (true) {
                count($written) === 1 => null,
                $tailored === 0 => 'The same copy was written to every campaign on this brief, so it was '
                    .'held to the tightest limits. If unused_room shows a platform with characters to '
                    .'spare, say so in one line and offer to rewrite that platform to use them.',
                $tailored === count($written) => 'Each platform got its own copy. Say so once, briefly.',
                default => 'Some platforms got their own copy and the rest share one set. Say so once, briefly.',
            },
        ], fn ($value) => $value !== null));
    }

    /**
     * Room a platform loses by sharing, in characters.
     *
     * The gap between this platform's limit and the tightest limit the shared
     * copy had to clear. Meta allows forty where Google allows thirty, so ten
     * characters of every shared headline are unavailable purely because one
     * set is being written for both.
     *
     * Measured against the constraint, not against the copy. The first version
     * compared the limit to the longest line actually written, which reported
     * Google as having twenty-five characters spare on a fifteen character
     * headline. That is true and useless: the copy was short because somebody
     * wrote it short, not because it was shared, and tailoring would not
     * recover a thing.
     *
     * @param  Collection<int, Campaign>  $sharedTargets
     * @return array<string, array<string, int>>|null
     */
    private function unusedRoom(Collection $sharedTargets): ?array
    {
        if ($sharedTargets->count() < 2) {
            return null;
        }

        $tightest = $this->tightestLimits($sharedTargets);
        $room = [];

        foreach ($sharedTargets as $target) {
            foreach ($this->platforms->for($target)->copyLimits() as $field => $max) {
                $lost = $max - ($tightest[$field] ?? $max);

                // A quarter of the tightest allowance, so a real difference
                // like forty against thirty is reported and a couple of
                // characters is not. A note nobody needs is how notes stop
                // being read.
                if ($lost >= (int) ceil(($tightest[$field] ?? $max) / 4)) {
                    $room[$target->platform][$field] = $lost;
                }
            }
        }

        return $room === [] ? null : $room;
    }

    /**
     * Every campaign this copy belongs to.
     *
     * The focused campaign and its brief's others. A campaign with no brief is
     * on its own, which is the case for one started separately later in a
     * conversation, and it keeps its own copy.
     *
     * Live campaigns are left alone: their ads exist on the platform, and
     * replacing the rows here would put our record out of step with what is
     * running without changing anything on the platform.
     *
     * @return Collection<int, Campaign>
     */
    private function sharingTheBrief(Campaign $campaign): Collection
    {
        if ($campaign->brief === null) {
            return collect([$campaign]);
        }

        return $campaign->brief->campaigns()
            ->get()
            ->reject(fn (Campaign $c): bool => $c->isPublished() && $c->isNot($campaign))
            ->values();
    }

    /**
     * The most variations any of these platforms insists on.
     *
     * Only Google asks for more than one, and only because its responsive
     * search ad is assembled from the headlines of every ad on the campaign.
     * Read from GoogleRules rather than written here, so the number lives with
     * the other Google limits.
     *
     * @param  Collection<int, Campaign>  $targets
     */
    private function leastVariations(Collection $targets): int
    {
        $least = 1;

        foreach ($targets as $target) {
            if ($this->platforms->for($target)->key() === 'google') {
                $least = max($least, GoogleRules::MIN_HEADLINES);
            }
        }

        return $least;
    }

    /**
     * The shortest each field may be across the platforms being written to.
     *
     * One piece of copy has to satisfy all of them, so the limit is the
     * tightest, not the focused campaign's. Meta takes a 40 character headline
     * and Google 30; copy written against Meta's limit is refused by Google at
     * publish, after the campaign and its budget already exist.
     *
     * @param  Collection<int, Campaign>  $targets
     * @return array<string, int>
     */
    private function tightestLimits(Collection $targets): array
    {
        $limits = [];

        foreach ($targets as $target) {
            foreach ($this->platforms->for($target)->copyLimits() as $field => $max) {
                $limits[$field] = isset($limits[$field]) ? min($limits[$field], $max) : $max;
            }
        }

        return $limits;
    }

    /**
     * The first variation that would be refused, named precisely.
     *
     * Refused rather than trimmed. Cutting a headline at 30 characters produces
     * copy nobody wrote and nobody approved, which then runs.
     *
     * @param  array<int, array<string, mixed>>  $ads
     * @param  array<string, int>  $limits
     */
    private function overLimit(array $ads, array $limits): ?string
    {
        foreach ($ads as $index => $ad) {
            foreach ($limits as $field => $max) {
                $value = trim((string) ($ad[$field] ?? ''));

                if ($value !== '' && mb_strlen($value) > $max) {
                    return sprintf(
                        'Ad %d\'s %s is %d characters and the limit here is %d: "%s".',
                        $index + 1,
                        str_replace('_', ' ', $field),
                        mb_strlen($value),
                        $max,
                        $value,
                    );
                }
            }
        }

        return null;
    }
}
