<?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\ArtifactFieldSource;
use App\Models\Campaign;
use App\Services\Meta\Meta;
use App\Services\Meta\MetaException;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

/** List the Pages an ad can be posted by, or attach one to the campaign in focus. */
class ChoosePage implements Tool
{
    use ActsOnFocusedCampaign;

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

    public function description(): Stringable|string
    {
        return 'List available Pages, or set one on the campaign in focus by passing page_id. '
            .'Requires the ad account to be verified first. Never invent a Page id.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'page_id' => $schema->string()
                ->description('Set this Page on the campaign. Omit to list the available Pages instead.'),
        ];
    }

    /**
     * A Page another campaign in this conversation already posts as.
     *
     * Evidence rather than a guess. The buyer picked it for a campaign about
     * the same thing, which is exactly what makes it worth putting first when
     * the brand name matches nothing. The ad account's name was the old
     * fallback and is not coming back: it says which account this is, not who
     * is advertising, and one account runs several brands.
     */
    private function pageChosenElsewhere(Campaign $campaign): ?string
    {
        $id = $this->workspace->artifacts(Campaign::class)
            ->map(fn ($link) => $link->artifactable)
            ->filter()
            ->reject(fn (Campaign $other): bool => $other->is($campaign))
            ->pluck('page_id')
            ->filter()
            ->first();

        return $id === null ? null : (string) $id;
    }

    /**
     * Move that Page to the front and mark it, leaving the rest alone.
     *
     * Still a reordering, like the brand match above it. The wrong Page is
     * somebody else's brand publishing somebody else's ad, so a bad guess has
     * to cost a scroll rather than the right answer.
     *
     * @param  list<array<string, mixed>>  $pages
     * @return list<array<string, mixed>>
     */
    private function putKnownPageFirst(array $pages, ?string $pageId): array
    {
        if (blank($pageId)) {
            return $pages;
        }

        $found = false;

        foreach ($pages as $i => $page) {
            $isKnown = (string) ($page['id'] ?? '') === $pageId;

            $pages[$i]['suggested'] = $isKnown;
            $pages[$i]['known'] = $isKnown;

            $found = $found || $isKnown;
        }

        if (! $found) {
            return $pages;
        }

        // Stable, so the order underneath the match survives.
        usort($pages, fn (array $a, array $b): int => ($b['suggested'] ?? false) <=> ($a['suggested'] ?? false));

        return $pages;
    }

    /**
     * Whether this Page and the landing page look like different brands.
     *
     * The same words NameMatch uses to suggest a Page, asked the other way
     * round: if nothing in the Page's name matches anything in the landing
     * page's, the two are probably not the same business.
     *
     * Silent when either side gives nothing to compare, because "no evidence"
     * and "evidence of a mismatch" are different answers and only one of them
     * is worth interrupting for.
     */
    private function brandMismatch(Campaign $campaign, array $page): ?string
    {
        $brand = NameMatch::words([$campaign->landing_url]);
        $pageName = trim((string) ($page['name'] ?? ''));

        if ($brand === [] || $pageName === '' || NameMatch::words([$pageName]) === []) {
            return null;
        }

        if (NameMatch::matches($pageName, $brand)) {
            return null;
        }

        return sprintf(
            'The ads will be published by "%s", which does not look like %s. Confirm this is the right Page.',
            $pageName,
            parse_url((string) $campaign->landing_url, PHP_URL_HOST) ?: 'the landing page',
        );
    }

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

        if (blank($campaign->ad_account_id)) {
            return $this->json(['ok' => false, 'error' => 'Verify the ad account first; Pages are account scoped.']);
        }

        $pageId = $request->all()['page_id'] ?? null;

        try {
            $pages = $this->meta->on($campaign->id, 'agent')->pages();
        } catch (MetaException $e) {
            return $this->json(['ok' => false, 'error' => $e->getMessage()]);
        }

        if (blank($pageId)) {
            // Nothing to choose from is not a question, it is a blocker.
            //
            // Choices::offer() ignores an empty option list, so this returned
            // ok with "the Pages are on screen" while the screen was unchanged,
            // and the model said its line and waited for a click that could
            // never come. Meta will not run an ad without a Page and nothing in
            // this product can make one, so the buyer has to be told that
            // plainly rather than left at a question with no answers.
            if ($pages === []) {
                return $this->json([
                    'ok' => false,
                    'error' => 'This ad account has no Facebook Page on it.',
                    'note' => 'Say that an ad cannot run without a Page, and that one has to be created or '
                        .'given access in Meta Business Suite before this campaign can go further. Do not ask '
                        .'the user to type a Page id: one they type would not be on the account either.',
                ]);
            }

            // The Page that matches the landing page goes first and says why.
            // A buyer advertising stockprices.com should not hunt for the
            // StockPrices Page in a list of fifty. Still every Page, only
            // reordered: a brand does sometimes post from a Page named after
            // something else.
            //
            // The landing page only. The ad account's name used to be a second
            // hint, and because the first hint to match anything wins outright,
            // a brand with no Page of its own fell through to it: advertising
            // attestpath.ai from the MidoWeb-WWBlog-GMT-RSOC account badged the
            // MidoWeb Pages "Suggested", which is a recommendation to post as
            // an unrelated brand, made with the same confidence as a real match.
            //
            // The account name is evidence about the account, not about who is
            // advertising, and one account runs several brands, which is the
            // reason it was there and the reason it cannot be trusted. When
            // nothing matches, nothing is suggested: the buyer picks, which is
            // what they would have had to do anyway.
            $suggested = NameMatch::suggestFirst($pages, [$campaign->landing_url]);

            // Nothing matched the brand, so fall back to evidence rather than
            // to a guess: a Page another campaign in this conversation already
            // posts as. That is something the buyer chose for the same thing
            // being advertised, not something read off the ad account's name,
            // which is the fallback that badged MidoWeb's Pages for
            // attestpath.ai and is deliberately not coming back.
            $matched = collect($suggested)->contains(fn (array $page): bool => $page['suggested'] ?? false);

            if (! $matched) {
                $suggested = $this->putKnownPageFirst($suggested, $this->pageChosenElsewhere($campaign));
                $matched = collect($suggested)->contains(fn (array $page): bool => $page['suggested'] ?? false);
            }

            // On screen rather than in a sentence: the ids are long and a page
            // is picked by recognising its name, not by reading a number.
            $this->choices->offer(
                field: 'page_id',
                question: 'Which Page should the ads be published as?',
                options: array_map(fn (array $page): array => [
                    'value' => (string) $page['id'],
                    'label' => $page['name'] ?? $page['id'],
                    'hint' => ($page['suggested'] ?? false) && ($page['known'] ?? false)
                        ? 'Another campaign in this chat posts as this Page.'
                        : ($page['category'] ?? null),
                    'badge' => ($page['suggested'] ?? false) ? 'Suggested' : null,
                    'sends' => 'Use Page '.$page['id'],
                ], $suggested),
            );

            return $this->json(array_filter([
                'ok' => true,
                'pages' => $pages,
                // Said so the model tells the buyer why nothing is badged,
                // instead of presenting fifty Pages as though one of them were
                // obviously right.
                'nothing_matched' => $matched ? null : 'No Page matches this landing page.',
                'note' => 'The Pages are on screen for the user to pick. Say one short line and wait. '
                    .'If nothing_matched is set, say that none of them match the site being advertised '
                    .'and that they should pick the Page this brand posts from.',
            ], fn ($value) => $value !== null));
        }

        $chosen = collect($pages)->firstWhere('id', (string) $pageId);

        if (! $chosen) {
            return $this->json([
                'ok' => false,
                'error' => "Page {$pageId} is not one this token can post as.",
                'pages' => $pages,
            ]);
        }

        $campaign->update([
            'page_id' => $chosen['id'],
            'meta' => [...($campaign->meta ?? []), 'facebook_page' => [
                'id' => (string) $chosen['id'],
                'name' => $chosen['name'] ?? null,
            ]],
        ]);
        $this->workspace->recordSource($campaign, 'page_id', ArtifactFieldSource::PLATFORM_RETURNED);

        // Set, and said out loud when the Page is not this brand's.
        //
        // Suggesting the wrong Page was fixed by not suggesting one at all when
        // nothing matches, and that left the other half: the buyer can still
        // pick any Page on the account, and every ad then runs under that
        // brand's name. Seen on arb-dev twice on 25 Sep, attestpath.ai and
        // webcrawlers.com both set to publish as "Stockprices.com", each right
        // after the product had said no Page matched.
        //
        // Not refused. One account legitimately runs several brands and the
        // buyer knows which Page is theirs. But a mismatch between who is
        // advertising and who the ad comes from is worth one sentence before
        // it reaches a platform, because afterwards it is somebody else's name
        // on the ad.
        $mismatch = $this->brandMismatch($campaign, $chosen);

        return $this->json(array_filter([
            'ok' => true,
            'page_id' => $chosen['id'],
            'name' => $chosen['name'] ?? null,
            'brand_mismatch' => $mismatch,
            // Said once, here, and not carried into every later turn.
            //
            // "ask them to confirm before publishing" made the model re-ask on
            // every subsequent reply until it got an answer, and a buyer who
            // does not treat it as a question never gives one. Mohsin was asked
            // whether The-Answer-Hub was the intended Page seven times in one
            // conversation, tacked onto the end of unrelated answers about
            // bidding, targeting, schedule and creatives.
            //
            // It does not need repeating: request_publish puts the Page and its
            // name in the review immediately before anything is created, which
            // is the moment the confirmation actually matters.
            'note' => $mismatch
                ? 'Say this once, plainly, in this reply only: the ads will be published by this Page '
                    .'and will carry its name, not the name on the landing page, and they can change '
                    .'the Page at any point before publishing. Do not ask again on later turns and do '
                    .'not append it to unrelated answers about budget, targeting or creatives.'
                : null,
        ], fn (mixed $value): bool => $value !== null));
    }
}
