<?php

namespace App\Agent\Tools\Campaign;

use App\Agent\Choices;
use App\Agent\Tools\Campaign\Concerns\ActsOnFocusedCampaign;
use App\Agent\Workspace;
use App\Campaigns\NameMatch;
use App\Models\Campaign;
use App\Models\CampaignAd;
use App\Models\Creative;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Collection;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

/**
 * Bring creatives made in the generator into this conversation.
 *
 * The generator runs outside any chat, so what it produces belongs to the user
 * but to no conversation. Without this the two halves of the product could not
 * reach each other: the chat would ask for an upload while a finished creative
 * sat one screen away.
 *
 * Called with no id it lists what is available and imports nothing, because the
 * user should choose. A variation group is several creatives on purpose, and
 * turning all of them into ads is a decision, not a default.
 */
class ImportCreatives implements Tool
{
    use ActsOnFocusedCampaign;

    /**
     * How many to put on screen.
     *
     * Was 25 with no way to reach an older one, which is a silent cap: the
     * picker looked complete and was not. The renderer gives a search box once
     * a set is large, so the number can be higher than anyone would scroll.
     */
    private const MOST_SHOWN = 60;

    /**
     * How much of a generated title a card can hold.
     *
     * Long enough that two creatives from the same product still differ inside
     * it, short enough not to wrap a card into a paragraph.
     */
    private const TITLE_SHOWN = 52;

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

    /** "1:1 image" or "9:16 video, 8s", which is what tells two apart at a glance. */
    /**
     * What this creative is, in the words of the thing it advertises.
     *
     * Was the shape and nothing else, so a buyer with four square images to
     * choose between read "1:1 Image" four times and had no way to tell them
     * apart. The generator writes a title for every creative and it was going
     * nowhere: "Women's Light-Wash Cuffed Straight-Leg Jeans" is the answer to
     * which one is this, and 1:1 never was. The shape moves to the hint, where
     * it still answers where will this run.
     *
     * Generated titles are occasionally long, and one source prefixes them with
     * the product URL, so they are trimmed to something a card can hold.
     */
    private function describe(Creative $creative): string
    {
        $title = $this->withoutDuplicatedColumns($creative, trim((string) ($creative->title ?? '')));

        if ($title !== '') {
            return mb_strlen($title) > self::TITLE_SHOWN
                ? rtrim(mb_substr($title, 0, self::TITLE_SHOWN), " \t\n\r-–—,.").'…'
                : $title;
        }

        return $this->shape($creative);
    }

    /**
     * Strips the ratio and type back out of a title that was built from them.
     *
     * Creatives made before 23 Sep were titled "{project}-{ratio}-{name}-{type}",
     * so the card said 1:1 and Image in the label and again in the hint, and the
     * real name was pushed past the truncation. The generator no longer does
     * this, but the library is full of titles that already do.
     *
     * Matched against this creative's own ratio and type rather than a guess at
     * the shape of a title, so a creative genuinely called "16:9 Launch Film"
     * keeps its name. Everything between the ratio and the type is the name,
     * which is why the leading project segment goes with the ratio: it was only
     * ever the product name repeated.
     */
    private function withoutDuplicatedColumns(Creative $creative, string $title): string
    {
        if ($title === '') {
            return '';
        }

        $ratio = trim((string) ($creative->ratio ?? ''));

        if ($ratio !== '' && preg_match('/^(?:.*?-)?'.preg_quote($ratio, '/').'-(.+)$/s', $title, $found) === 1) {
            $title = trim($found[1]);
        }

        $type = ucfirst((string) ($creative->type ?? ''));

        if ($type !== '' && str_ends_with($title, '-'.$type)) {
            $title = mb_substr($title, 0, -mb_strlen('-'.$type));
        }

        // The old titles were joined on hyphens and the name was cut to 30
        // characters before joining, so a name truncated at a word boundary
        // leaves its own trailing hyphen behind once the type is removed:
        // "120Hr Hybrid ANC Escape-". Strip separators from both ends rather
        // than only whitespace.
        return trim($title, " \t\n\r-–—·|");
    }

    /** The shape, which is the fallback label and always part of the hint. */
    private function shape(Creative $creative): string
    {
        $kind = $creative->type === 'video' ? 'Video' : 'Image';

        return trim(($creative->ratio ? $creative->ratio.' ' : '').strtolower($kind)) === ''
            ? $kind
            : ucfirst(trim(($creative->ratio ? $creative->ratio.' ' : '').strtolower($kind)));
    }

    /**
     * Everything else worth knowing before picking it.
     *
     * The shape leads now that the label carries the title: it is what decides
     * where the creative can run, and a buyer choosing between a 1:1 and a 9:16
     * is choosing between feed and story whether or not they think of it that
     * way. "from product_url" is left as the generator words it rather than
     * prettified, because it is the only thing distinguishing two creatives made
     * from the same page by different routes.
     */
    private function detail(Creative $creative, ?string $usedIn): string
    {
        $parts = array_filter([
            $this->shape($creative),
            $creative->duration ? $creative->duration.'s' : null,
            $creative->source ? 'from '.$creative->source : null,
            $usedIn ? 'already in '.$usedIn : null,
        ]);

        return implode(', ', $parts);
    }

    /**
     * What this creative is about, in words, for matching against a campaign.
     *
     * The product URL is the strongest signal because it is the page the
     * creative was made for; the prompt is what someone typed about it.
     */
    private function searchableName(Creative $creative): string
    {
        return trim(implode(' ', array_filter([
            $creative->title,
            $creative->project_name,
            $creative->product_url,
            $creative->prompt,
        ])));
    }

    /**
     * Which campaign, if any, is already running each of these.
     *
     * Reusing a creative is normal and often deliberate, so this is shown
     * rather than prevented.
     *
     * @param  Collection<int, Creative>  $creatives
     * @return array<int, string> asset id to campaign name
     */
    private function alreadyUsed($creatives): array
    {
        $assetIds = $creatives->pluck('asset_id')->filter()->all();

        if ($assetIds === []) {
            return [];
        }

        return CampaignAd::query()
            ->whereIn('asset_id', $assetIds)
            ->with('campaign:id,name')
            ->get()
            ->filter(fn (CampaignAd $ad): bool => $ad->campaign !== null)
            ->mapWithKeys(fn (CampaignAd $ad): array => [$ad->asset_id => $ad->campaign->name])
            ->all();
    }

    public function description(): Stringable|string
    {
        return 'List the creatives the user generated in the creative generator, and bring the '
            .'chosen ones into this conversation so they can be attached to a campaign. Call '
            .'with no arguments to show what is available.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'creative_ids' => $schema->array()->items($schema->integer())
                ->description('Ids to import. Omit to list what is available without importing.'),
        ];
    }

    public function handle(Request $request): Stringable|string
    {
        $user = $this->workspace->owner();

        if (! $user) {
            return 'error: no signed-in user, so there are no generated creatives to look at.';
        }

        $ids = collect((array) ($request->all()['creative_ids'] ?? []))
            ->filter()
            ->map(intval(...))
            ->all();

        // Only finished ones, and only those that produced a usable file. A
        // creative still rendering has nothing to attach yet.
        // Scoped to the signed-in user, which reverses an earlier decision.
        //
        // This showed everyone's creatives, on the reasoning that they are all
        // usable by everyone here and the campaign records who used what. The
        // reasoning was wrong about the product: a creative belongs to whoever
        // generated it and always has, so the shared listing was not enabling
        // anything, it was filling the picker with dozens of unrelated items to
        // scroll past to reach your own. Asked for by name in the 29 Sep feedback
        // and confirmed on 30 Sep that nobody depended on the shared listing.
        //
        // No approved-only filter yet, because creatives have no approved state:
        // the column does not exist and the approval flow is unbuilt. Once it
        // lands this is where it belongs.
        $available = Creative::query()
            ->where('user_id', $user->id)
            ->where('status', 'completed')
            ->whereNotNull('asset_id')
            ->with('asset')
            ->latest('id')
            ->limit(self::MOST_SHOWN)
            ->get();

        if ($available->isEmpty()) {
            return 'No finished creatives exist yet. Tell the user they can make one in the creative '
                .'generator at '.route('creatives.create').', or attach an image or video to this chat. '
                .'Give them the link; do not offer to generate one yourself.';
        }

        if ($ids === []) {
            $campaign = $this->workspace->focus();
            $usedIn = $this->alreadyUsed($available);

            // Newest first is only right when nothing is known about what the
            // creative is for. The campaign says what it is for, so a creative
            // made for the same site comes first.
            $relevant = NameMatch::suggestFirst(
                $available->map(fn (Creative $c): array => ['creative' => $c, 'name' => $this->searchableName($c)])->all(),
                $campaign instanceof Campaign ? [$campaign->landing_url, $campaign->name] : [],
            );

            // Shown, not listed by id. A creative is chosen by looking at it,
            // which is the whole reason the preview route exists.
            $this->choices->offer(
                field: 'creative_ids',
                question: 'Which creatives should be used?',
                options: array_map(function (array $row) use ($usedIn): array {
                    $c = $row['creative'];

                    return [
                        'value' => (string) $c->id,
                        'label' => $this->describe($c),
                        'hint' => $this->detail($c, $usedIn[$c->asset_id] ?? null),
                        'badge' => ($row['suggested'] ?? false) ? 'Suggested' : null,
                        'image' => $c->asset && $c->asset->kind === 'image'
                            ? route('assets.preview', $c->asset)
                            : null,
                        // Videos have no poster frame stored anywhere, so they
                        // get the shape of a card without the picture. Left
                        // blank they were 10 empty cards among 17 with images.
                        'icon' => $c->type === 'video' ? 'bi-film' : null,
                        'sends' => 'Import creative '.$c->id,
                    ];
                }, $relevant),
                multiple: true,
            );

            return sprintf(
                '%d creative(s) are on screen for the user to pick. Say one short line and wait.',
                count($relevant),
            );
        }

        $chosen = $available->whereIn('id', $ids);

        if ($chosen->isEmpty()) {
            return 'error: none of those ids are finished creatives belonging to this user.';
        }

        // Without focus: false these would each take focus in turn, leaving the
        // conversation pointed at a creative rather than the campaign it was
        // just brought in for, and the next tool finds no campaign.
        foreach ($chosen as $creative) {
            $this->workspace->produced($creative->asset, focus: false);
        }

        $campaign = $this->focused();
        $hasCopy = $campaign && $campaign->ads()->exists();

        if (! $hasCopy) {
            $this->offerCopyEmphasis();
        }

        $missing = array_diff($ids, $chosen->pluck('id')->all());
        $idsList = $chosen->pluck('id')->implode(', ');

        $emphasisPrompt = ! $hasCopy
            ? sprintf(' Copy emphasis suggestions are on screen. Ask "Creative %s is imported. What message or offer should the ad copy emphasize?" and wait for the user to choose or type their own.', $idsList)
            : '';

        return sprintf(
            'Imported %d creative(s) into this conversation: %s.%s%s',
            $chosen->count(),
            $idsList,
            $missing ? ' Not found or not finished: '.implode(', ', $missing).'.' : '',
            $emphasisPrompt,
        );
    }

    /**
     * Suggestions for what the ad copy should emphasize once creatives are imported.
     */
    private function offerCopyEmphasis(): void
    {
        $options = [
            [
                'value' => 'Key Benefits & Features',
                'label' => 'Key Benefits & Features',
                'hint' => 'Highlight top product advantages and unique value',
                'sends' => 'Emphasize key benefits and features in the ad copy.',
            ],
            [
                'value' => 'Special Promotion & Discount',
                'label' => 'Special Promotion & Discount',
                'hint' => 'Drive conversions with a deal, savings, or promo',
                'sends' => 'Emphasize a special promotion and discount in the ad copy.',
            ],
            [
                'value' => 'Customer Trust & Social Proof',
                'label' => 'Customer Trust & Social Proof',
                'hint' => 'Build confidence with ratings, reviews, and satisfaction guarantee',
                'sends' => 'Emphasize customer trust, reviews, and social proof in the ad copy.',
            ],
        ];

        $this->choices->offer(
            field: 'copy_emphasis',
            question: 'What message or offer should the ad copy emphasize?',
            options: $options,
            multiple: false,
            allowOther: true,
        );
    }
}
