<?php

namespace App\Agent\Tools\Campaign;

use App\Agent\Choices;
use App\Agent\Tools\Campaign\Concerns\ActsOnFocusedCampaign;
use App\Agent\Workspace;
use App\Models\ArtifactFieldSource;
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;

/**
 * Picking the wording, rather than being handed it.
 *
 * write_ad_copy produces a set of variations and stores them, and that was the
 * end of it: the only way to change a headline was to ask for the whole set
 * again in prose and hope the one line you liked survived. Buyers did exactly
 * that - "keep 2 but make the first one shorter" - and got three new ads none of
 * which were the ones they had approved.
 *
 * So the alternatives are offered as a question like every other question in the
 * flow. The model writes several lines, this puts them on screen, and the click
 * applies server-side without a model turn. Three things come out of that for
 * free: the buyer's own wording through "write my own", a regenerate that asks
 * for fresh options rather than a fresh campaign, and a record that the line was
 * chosen rather than generated.
 *
 * One ad at a time, because the variations are meant to differ. Applying a
 * chosen headline to all of them would collapse the set into three copies of
 * itself, which is the opposite of what a variation is for.
 */
class ChooseAdCopy implements Tool
{
    use ActsOnFocusedCampaign;

    /**
     * The index and the text travel together in the value.
     *
     * Answers applies a click by field name alone, and the field names are a
     * static map, so "ad 2's headline" cannot be a field of its own. Carrying
     * the index in the value keeps the apply path stateless: nothing has to
     * remember which ad was being edited between the offer and the click, which
     * is the part that breaks when two questions are on screen at once.
     */
    private const SEPARATOR = '::';

    /** Asking for a different set, rather than a different campaign. */
    public const REGENERATE = '__regenerate';

    /** What each platform calls the body of the ad, in its own words. */
    private const BODY_LABEL = [
        'meta' => 'primary text',
        'google' => 'description',
        'linkedin' => 'introductory text',
    ];

    public function __construct(
        private readonly Workspace $workspace,
        private readonly Choices $choices,
    ) {}

    public function description(): Stringable|string
    {
        return 'Offer alternative wording for one ad and apply what the user picks. Pass ad (1 for the '
            .'first variation) with headlines and/or primary_texts to put options on screen, several of '
            .'each. Call this when the user wants different wording, a shorter headline, or to choose '
            .'between lines. Do not call write_ad_copy again for a wording change: that replaces every '
            .'variation, including the ones already approved.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'ad' => $schema->integer()
                ->description('Which variation to offer wording for, counting from 1. Defaults to the first.'),
            'headlines' => $schema->array()
                ->items($schema->string())
                ->description('Alternative headlines to offer. Write three or four, all different.'),
            'primary_texts' => $schema->array()
                ->items($schema->string())
                ->description('Alternative body copy to offer. Write three or four, all different.'),
            'headline' => $schema->string()->description('A headline to apply directly, without offering a choice.'),
            'primary_text' => $schema->string()->description('Body copy to apply directly, without offering a choice.'),
        ];
    }

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

        $ads = $campaign->ads()->orderBy('id')->get();

        if ($ads->isEmpty()) {
            return $this->json([
                'ok' => false,
                'error' => 'There is no ad copy to change yet. Call write_ad_copy first.',
            ]);
        }

        $arguments = $request->all();

        // Applying comes first: a click arrives as headline or primary_text
        // with the index folded into it, and must not be mistaken for a request
        // to offer the same question again.
        foreach (['headline', 'primary_text'] as $field) {
            if (array_key_exists($field, $arguments)) {
                return $this->apply($campaign, $ads, $field, (string) $arguments[$field]);
            }
        }

        $index = max(1, (int) ($arguments['ad'] ?? 1));

        if (! $ad = $ads->get($index - 1)) {
            return $this->json([
                'ok' => false,
                'error' => sprintf('There is no variation %d. The campaign has %d.', $index, $ads->count()),
            ]);
        }

        $headlines = $this->clean($arguments['headlines'] ?? []);
        $bodies = $this->clean($arguments['primary_texts'] ?? []);

        if ($headlines === [] && $bodies === []) {
            return $this->json([
                'ok' => false,
                'error' => 'Write the alternatives first: pass headlines, primary_texts, or both.',
                'note' => 'Write three or four of each, genuinely different from one another and from what is there now.',
            ]);
        }

        return $this->offer($campaign, $ad, $index, $headlines, $bodies);
    }

    /**
     * @param  list<string>  $headlines
     * @param  list<string>  $bodies
     */
    private function offer(Campaign $campaign, CampaignAd $ad, int $index, array $headlines, array $bodies): Stringable|string
    {
        $bodyLabel = self::BODY_LABEL[$campaign->platform] ?? 'body copy';

        if ($headlines !== []) {
            $this->choices->offer(
                field: 'ad_headline',
                question: sprintf('Which headline should variation %d use?', $index),
                options: $this->options($index, $headlines, $ad->headline, 'headline'),
                allowOther: true,
            );
        }

        if ($bodies !== []) {
            $this->choices->offer(
                field: 'ad_primary_text',
                question: sprintf('Which %s should variation %d use?', $bodyLabel, $index),
                options: $this->options($index, $bodies, $ad->primary_text, $bodyLabel),
                allowOther: true,
            );
        }

        return $this->json([
            'ok' => true,
            'ad' => $index,
            'offered' => ['headlines' => count($headlines), 'primary_texts' => count($bodies)],
            'note' => 'The options are on screen. Say one short line and wait; do not restate them.',
        ]);
    }

    /**
     * @param  list<string>  $candidates
     * @return list<array<string, string>>
     */
    private function options(int $index, array $candidates, ?string $current, string $what): array
    {
        $options = array_map(fn (string $text): array => array_filter([
            'value' => $index.self::SEPARATOR.$text,
            'label' => $text,
            'badge' => $text === $current ? 'Current' : null,
            'sends' => sprintf('Use "%s" as the %s for variation %d', $text, $what, $index),
        ]), $candidates);

        // Offered beside the alternatives rather than left to be typed, because
        // asking for different wording in prose is what produced a whole new set
        // of ads and lost the variations already approved.
        $options[] = [
            'value' => $index.self::SEPARATOR.self::REGENERATE,
            'label' => 'Write me different options',
            'sends' => sprintf('Write me different %s options for variation %d', $what, $index),
        ];

        return $options;
    }

    /**
     * @param  Collection<int, CampaignAd>  $ads
     */
    private function apply(Campaign $campaign, $ads, string $field, string $raw): Stringable|string
    {
        [$index, $text] = $this->split($raw);

        if (! $ad = $ads->get($index - 1)) {
            return $this->json([
                'ok' => false,
                'error' => sprintf('There is no variation %d. The campaign has %d.', $index, $ads->count()),
            ]);
        }

        // A request for different options is not a value to store. Refused so
        // the sentence reaches the model, which writes a fresh set and offers
        // it through this tool again.
        if ($text === self::REGENERATE) {
            return $this->json([
                'ok' => false,
                'error' => 'That is a request for different options, not a choice.',
                'note' => sprintf(
                    'Write three or four new %s options for variation %d, different from the ones just offered, '
                    .'and call choose_ad_copy with them. Change nothing else.',
                    $field === 'headline' ? 'headline' : 'body copy',
                    $index,
                ),
            ]);
        }

        if (trim($text) === '') {
            return $this->json(['ok' => false, 'error' => 'That is empty. Give the wording to use.']);
        }

        $ad->update([$field => trim($text)]);

        // Recorded against the campaign because that is what the panel reads,
        // and the point of the record is that the line was chosen by the buyer
        // rather than written for them.
        $this->workspace->recordSource($campaign, $field, ArtifactFieldSource::USER_STATED);

        return $this->json([
            'ok' => true,
            'ad' => $index,
            $field => trim($text),
        ]);
    }

    /**
     * @return array{int, string}
     */
    private function split(string $raw): array
    {
        $at = strpos($raw, self::SEPARATOR);

        // Typed rather than clicked. "Write my own" sends the wording with no
        // index in front of it, and the first variation is the one being
        // offered unless the model said otherwise.
        if ($at === false || ! ctype_digit(substr($raw, 0, $at))) {
            return [1, $raw];
        }

        return [(int) substr($raw, 0, $at), substr($raw, $at + strlen(self::SEPARATOR))];
    }

    /**
     * @param  mixed  $values
     * @return list<string>
     */
    private function clean($values): array
    {
        return collect(is_array($values) ? $values : [])
            ->map(fn ($v): string => trim((string) $v))
            ->filter()
            ->unique()
            ->values()
            ->all();
    }
}
