<?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\Objectives;
use App\Campaigns\Platforms\Platforms;
use App\Campaigns\Targeting\CountryCatalog;
use App\Campaigns\Targeting\TargetingSelections;
use App\Models\ArtifactFieldSource;
use App\Models\Campaign;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Http\Client\ConnectionException;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use RuntimeException;
use Stringable;

/**
 * Audience, countries and placements for the campaign in focus.
 *
 * Stored on the campaign itself, which on Meta are then written to its ad set
 * on publish.
 */
class SetTargeting implements Tool
{
    use ActsOnFocusedCampaign;

    public function __construct(
        private readonly Workspace $workspace,
        private readonly Choices $choices,
        private readonly Platforms $platforms,
        private readonly Briefs $briefs,
        private readonly CountryCatalog $countries,
        private readonly TargetingSelections $selections,
    ) {}

    /**
     * Where this campaign's platform can actually put an ad.
     *
     * Not a constant, because the constant is Meta's. Asking a Google campaign
     * whether to run on Messenger, and then writing "facebook" to it, is what
     * happened before this: the list offered and the list validated against were
     * both {@see Objectives::PLACEMENTS} regardless of the platform in focus.
     *
     * An empty list means the platform does not model placements yet. That is
     * not the same as "no placements chosen", so nothing is offered or written,
     * and {@see LaunchFlow} treats the step as inapplicable
     * rather than leaving it outstanding forever.
     *
     * @return list<string>
     */
    private function supportedPlacements(Campaign $campaign): array
    {
        return $this->platforms->for($campaign)->placements();
    }

    public function description(): Stringable|string
    {
        return 'Set audience and placement on the campaign in focus: countries, age range, '
            .'genders, devices and placements. Call with no arguments to offer choices '
            .'for anything not yet set.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'countries' => $schema->array()->items($schema->string())
                ->description('Two-letter ISO codes (e.g. ["US", "GB"]). Omit to offer the searchable global country list.'),
            'age_min' => $schema->integer()->description('Minimum age, 13 to 65.'),
            'age_max' => $schema->integer()->description('Maximum age, 13 to 65.'),
            'genders' => $schema->array()->items($schema->integer())
                ->description('1 for male, 2 for female. Omit to reach everyone.'),
            'devices' => $schema->array()->items($schema->string())
                ->description('Any of mobile, desktop. Omit for both.'),
            // Deliberately does not list the values. The list is the platform's,
            // and this description is written once for every campaign, so naming
            // Meta's placements here told the model to offer Messenger on a
            // Google campaign. Calling with no placements puts the right ones on
            // screen for whichever platform is in focus.
            'placements' => $schema->array()->items($schema->string())
                ->description('Where the ads should appear. The accepted values depend on the '
                    .'platform this campaign runs on, so omit this to have the valid ones listed '
                    .'for the user to pick from rather than guessing them.'),
        ];
    }

    /** Placements are a closed list, so every one of them can be a button. */
    private function offerPlacements(Campaign $campaign): void
    {
        $supported = $this->supportedPlacements($campaign);

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

        $this->choices->offer(
            field: 'placements',
            question: 'Where should the ads appear?',
            options: array_map(fn (string $p): array => [
                'value' => $p,
                'label' => ucfirst(str_replace('_', ' ', $p)),
                'sends' => 'Run on '.$p,
            ], $supported),
            multiple: true,
        );
    }

    /**
     * The hint has to come from the campaign, not from Meta.
     *
     * "Most Meta inventory" was written on the mobile option and shown on every
     * platform, so a Google Search campaign was told about Meta's inventory
     * while choosing its devices. Seen on arb-dev on 25 Sep. Each platform has
     * its own reason to go mobile only, and the one that does not apply is
     * worse than none: it reads as the product not knowing which campaign it is
     * looking at.
     */
    private function mobileHint(Campaign $campaign): ?string
    {
        return match ($campaign->platform) {
            'meta' => 'Most Meta inventory. Worth it when the landing page is built for phones.',
            'google' => 'Most search volume. Worth it when the landing page is built for phones.',
            'linkedin' => 'Where most LinkedIn browsing happens. Worth it when the page is built for phones.',
            default => 'Worth it when the landing page is built for phones.',
        };
    }

    /**
     * Device targeting, which was supported everywhere except in the asking.
     *
     * AdSetSpec has always sent device_platforms and the tool has always
     * accepted them, and nothing ever put the question in front of anyone, so
     * every campaign ran on both whether or not the landing page worked on a
     * phone. Offered as one choice rather than a multi-select: the useful
     * answers are all, one, or the other.
     */
    private function offerDevices(Campaign $campaign): void
    {
        $this->choices->offer(
            field: 'devices',
            question: 'Which devices?',
            options: [
                [
                    'value' => 'mobile,desktop',
                    'label' => 'All devices',
                    'badge' => 'Suggested',
                    'sends' => 'Target both mobile and desktop',
                ],
                [
                    'value' => 'mobile',
                    'label' => 'Mobile only',
                    'hint' => $this->mobileHint($campaign),
                    'sends' => 'Target mobile only',
                ],
                [
                    'value' => 'desktop',
                    'label' => 'Desktop only',
                    'sends' => 'Target desktop only',
                ],
            ],
        );
    }

    /**
     * Record the countries on the brief so no later platform has to ask.
     *
     * Never overwritten. The brief holds what the buyer first said, and a
     * campaign given its own narrower list afterwards must not quietly
     * redefine where the whole brief runs. The campaign keeps its own value
     * either way; this is only what the next platform inherits.
     *
     * @param  list<string>  $countries
     */
    private function rememberCountries(array $countries): void
    {
        $brief = $this->briefs->current();

        if ($countries === [] || $brief === null || filled($brief->locations['countries'] ?? null)) {
            return;
        }

        $this->selections->rememberBriefCountries($brief, $countries);
    }

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

        if ($campaign->isPublished()) {
            return $this->json([
                'ok' => false,
                'error' => 'Audience and location targeting cannot be changed on a published campaign. Create a new campaign for a different audience.',
            ]);
        }

        $arguments = $request->all();

        $availableCountries = $this->countries->names();
        $countries = array_values(array_unique(array_filter(array_map(
            fn ($c) => strtoupper(trim((string) $c)),
            (array) ($arguments['countries'] ?? $campaign->locations['countries'] ?? []),
        ))));

        // Offering beats refusing. This used to be an error telling the user
        // something was required without showing what the answers could be.
        if ($countries === []) {
            $verified = $this->countries->verified((string) $campaign->platform);
            $this->choices->offer(
                field: 'countries',
                question: 'Which countries should this run in? Search by country name or code and select all that apply.',
                options: array_map(fn (string $code): array => [
                    'value' => $code,
                    'label' => $availableCountries[$code],
                    'hint' => $code,
                    'badge' => isset($verified[$code]) ? 'Verified' : 'Check at publish',
                    'sends' => 'Target '.$availableCountries[$code],
                ], array_keys($availableCountries)),
                multiple: true,
            );

            $this->offerPlacements($campaign);
            $this->offerDevices($campaign);

            return $this->json([
                'ok' => true,
                'note' => 'Countries are on screen for the user to pick. Say one short line and wait.',
            ]);
        }

        if ($invalid = array_diff($countries, array_keys($availableCountries))) {
            return $this->json([
                'ok' => false,
                'error' => 'Unrecognised country codes: '.implode(', ', $invalid).'. Choose countries from the list.',
            ]);
        }

        if (filled($arguments['countries'] ?? null) && in_array($campaign->platform, ['meta', 'google', 'linkedin'], true)) {
            try {
                $this->countries->resolve((string) $campaign->platform, $countries, $campaign->ad_account_id);
            } catch (RuntimeException|ConnectionException $exception) {
                return $this->json(['ok' => false, 'error' => $exception->getMessage()]);
            }
        }

        // Countries are settled but nothing has said where the ads should run.
        if (blank($arguments['placements'] ?? null) && blank($campaign->placements)) {
            $this->offerPlacements($campaign);
        }

        if (blank($arguments['devices'] ?? null) && blank($campaign->audience['devices'] ?? null)) {
            $this->offerDevices($campaign);
        }

        $supported = $this->supportedPlacements($campaign);

        $devices = $this->limitTo($arguments['devices'] ?? null, Objectives::DEVICES);
        $placements = $this->limitTo($arguments['placements'] ?? null, $supported);

        if (($arguments['devices'] ?? null) && $devices === []) {
            return $this->json(['ok' => false, 'error' => 'Devices must be mobile and/or desktop.']);
        }

        if (($arguments['placements'] ?? null) && $placements === []) {
            return $this->json([
                'ok' => false,
                'error' => $supported === []
                    ? $this->platforms->for($campaign)->name().' does not take placements here, so leave them unset.'
                    : 'Unrecognised placements.',
                'supported' => $supported,
            ]);
        }

        $audience = array_filter([
            'age_min' => isset($arguments['age_min']) ? (int) $arguments['age_min'] : ($campaign->audience['age_min'] ?? null),
            'age_max' => isset($arguments['age_max']) ? (int) $arguments['age_max'] : ($campaign->audience['age_max'] ?? null),
            'genders' => array_map('intval', (array) ($arguments['genders'] ?? $campaign->audience['genders'] ?? [])) ?: null,
            'devices' => $devices ?: ($campaign->audience['devices'] ?? null),
        ], fn ($v) => $v !== null && $v !== []);

        $this->selections->saveCampaignCountries($campaign, $countries);

        $campaign->update([
            'audience' => $audience,
            'placements' => $placements ?: $campaign->placements,
        ]);

        // Countries belong to the brief, not to one platform's campaign.
        //
        // locations has been in SHARED_WITH_BRIEF since the trait was written,
        // and nothing ever wrote it, so the fallback it describes could never
        // fire: every platform started with none and was asked again. "Where
        // should this run" has one answer for a brief, and the buyer gave it
        // three times on arb-dev on 25 Sep.
        //
        // Placements and devices stay on the campaign. Meta sells Messenger
        // and Google sells Search, so those genuinely differ per platform;
        // countries never do.
        $this->rememberCountries($countries);

        // Only what was actually put on screen and answered counts as the
        // user's. Countries and placements were being recorded as theirs while
        // the model chose both without asking.
        //
        // And only what this call set at all. Answering countries used to stamp
        // a source on placements and audience too, so a field nobody had
        // touched inherited the provenance of one that had been.
        $touched = [
            'locations' => ['countries', filled($arguments['countries'] ?? null)],
            'audience' => ['devices', $audience !== []],
            'placements' => ['placements', filled($arguments['placements'] ?? null)],
        ];

        foreach ($touched as $field => [$offered, $changed]) {
            if (! $changed) {
                continue;
            }

            $this->workspace->recordSource($campaign, $field, $this->workspace->wasOffered($offered)
                ? ArtifactFieldSource::USER_STATED
                : ArtifactFieldSource::MODEL_INFERRED);
        }

        return $this->json([
            'ok' => true,
            'campaign_id' => $campaign->id,
            'countries' => $countries,
            'audience' => $audience,
            'placements' => $campaign->placements,
        ]);
    }

    /** @return list<string> */
    private function limitTo(mixed $given, array $allowed): array
    {
        return array_values(array_intersect(
            array_map(fn ($v) => strtolower(trim((string) $v)), (array) $given),
            $allowed,
        ));
    }
}
