<?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\Campaigns\Platforms\Platforms;
use App\Models\ArtifactFieldSource;
use App\Models\Campaign;
use App\Models\GoogleAdsConnection;
use App\Services\LinkedIn\LinkedInRestApi;
use App\Services\LinkedIn\LinkedInUrn;
use App\Services\Meta\Meta;
use App\Services\Meta\MetaException;
use App\Services\Platforms\Support\Guardrails;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

/**
 * The ad accounts this product may use.
 *
 * Reads the allow list and fetches each account directly rather than asking the
 * platform for everything the token can see. Those are the only usable accounts
 * anyway, and the me/adaccounts edge sweeps every account on the token, sharing
 * an hourly limit with production reporting jobs and getting throttled first.
 */
class ListAdAccounts implements Tool
{
    use ActsOnFocusedCampaign;

    public function __construct(
        private readonly Workspace $workspace,
        private readonly Meta $meta,
        private readonly Choices $choices,
        private readonly ?LinkedInRestApi $linkedInApi = null,
        private readonly ?Guardrails $guardrails = null,
    ) {}

    public function description(): Stringable|string
    {
        return 'List the ad accounts available. If the user already named an account, '
            .'skip this and call verify_ad_account instead.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [];
    }

    /**
     * The campaign name, but only if a person picked it.
     *
     * A generated name says nothing about the brand and would match on the
     * month it was made in. Provenance already records which it was.
     */
    private function chosenName(?Campaign $campaign): ?string
    {
        if (! $campaign) {
            return null;
        }

        return ($this->workspace->sourcesFor($campaign)['name'] ?? null) === ArtifactFieldSource::USER_STATED
            ? $campaign->name
            : null;
    }

    /**
     * The one Google account, taken from configuration rather than asked for.
     *
     * Not offered as a single-option question: a choice between one thing is
     * not a choice, and the buyer cannot change it anyway.
     */
    private function useSharedGoogleAccount(Campaign $campaign): string
    {
        $connection = GoogleAdsConnection::shared();

        if ($connection === null) {
            return $this->json([
                'ok' => false,
                'error' => ucfirst(GoogleAdsConnection::missingReason()).'.',
                'note' => 'This needs an administrator, not an answer from the user. Say so and stop.',
            ]);
        }

        $customerId = str_replace('-', '', (string) $connection->customer_id);

        // Checked here rather than at publish, where it used to be the only
        // check. Meta's branch above builds its list from the allowed accounts
        // and LinkedIn's filters against them, so Google was the one platform
        // that adopted an account without asking whether it was permitted.
        //
        // Campaign 97 on arb-dev is what that costs: customer 3460855874 was
        // set from configuration, every following question was answered, three
        // ads were written, and the publish then failed with "3460855874 is not
        // on the allowed list" against a list holding two Meta accounts and no
        // Google one. Nothing before that moment had any reason to doubt it.
        if (! ($this->guardrails ?? app(Guardrails::class))->allows($customerId, 'google')) {
            return $this->json([
                'ok' => false,
                'error' => sprintf(
                    'The shared Google Ads account %s is not on the allowed list, so nothing can be '
                    .'published to it. Someone needs to add it to PLATFORM_ALLOWED_AD_ACCOUNTS.',
                    $customerId,
                ),
                'note' => 'This needs an administrator, not an answer from the user. Say so and stop.',
            ]);
        }

        if ($campaign->ad_account_id !== $customerId) {
            $campaign->update(['ad_account_id' => $customerId]);
            $this->workspace->recordSource($campaign, 'ad_account_id', ArtifactFieldSource::PLATFORM_RETURNED);
        }

        return $this->json([
            'ok' => true,
            'ad_account_id' => $customerId,
            'note' => 'Google runs on one shared account, which is now set. Do not ask the user to choose '
                .'an account and do not call verify_ad_account. Move on to the next step.',
        ]);
    }

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

        // Google runs on one common internal account, so there is nothing to
        // pick and nothing to verify: the id comes from configuration, the same
        // way the Meta system user token does.
        //
        // This step used to fall through to the refusal below, which told a
        // Google buyer that Google "is not connected yet" while it was in fact
        // connected and answering. With no accounts on screen the model asked
        // for one in prose, and the flow stopped at its first step with a
        // question the buyer had no way to answer. The publisher never wanted
        // it either: it reads the customer id off the connection. It is written
        // to the campaign because the publish gate requires one, and because a
        // panel that names the account is worth more than a blank row.
        if ($campaign && $campaign->platform === 'google') {
            return $this->useSharedGoogleAccount($campaign);
        }

        if ($campaign && $campaign->platform === 'linkedin') {
            return $this->handleLinkedIn($campaign);
        }

        // These are Meta accounts. Returning them for a campaign on another
        // platform invites the model to offer them as a choice, and then to ask
        // for an account id for a platform that is not connected at all.
        if ($campaign && $campaign->platform !== 'meta') {
            return $this->json([
                'ok' => false,
                'error' => sprintf(
                    '%s is not connected yet, so it has no ad accounts to list.',
                    app(Platforms::class)->for($campaign)->name(),
                ),
                'note' => 'Say it is not connected. Do not ask the user for an account id for it.',
            ]);
        }

        $guardrails = $this->guardrails ?? app(Guardrails::class);
        $allowed = $guardrails->permittedAccounts('meta');

        if ($allowed === []) {
            return $this->json([
                'ok' => false,
                'error' => 'No ad accounts are configured. Someone needs to set PLATFORM_ALLOWED_AD_ACCOUNTS.',
            ]);
        }

        $meta = $this->meta->on($this->focused()?->id, 'agent');
        $accounts = [];
        $unreachable = [];

        foreach ($allowed as $id) {
            try {
                $account = $meta->adAccount($id);

                $accounts[] = [
                    'id' => $account['id'],
                    'name' => $account['name'] ?? null,
                    'currency' => $account['currency'] ?? null,
                    'usable' => (int) ($account['account_status'] ?? 0) === 1,
                ];
            } catch (MetaException $e) {
                $unreachable[] = ['id' => $id, 'reason' => $e->getMessage()];
            }
        }

        // Put them on screen rather than making the user copy an act_ id out of
        // a sentence. Only accounts the guardrail allows are ever offered, and
        // an unusable one is shown as unusable rather than quietly dropped.
        //
        // The account that looks like what they are advertising goes first and
        // says why. A suggestion, not a decision: the rest stay on screen,
        // because a brand often buys from an account named after something else.
        //
        // What they typed is the hint that actually exists here. The landing
        // page is kept for the case where this is called again later, but at
        // this point in the flow it is always empty: the ad account is the first
        // question and the URL is the fourth, so matching on it alone meant the
        // suggestion could never once fire. Somebody opening with "a traffic
        // campaign for stockprices.com" still had to find StockPrices in the list.
        //
        // The generated campaign name is not a hint: it is "Meta campaign
        // 16 Sep 06:45", and "sep" is a word that matches things.
        if (! $this->offerAccounts($accounts, $campaign, 'Which ad account should this campaign run on?')) {
            return $this->noneToOffer($accounts, $unreachable, 'Meta');
        }

        return $this->json(array_filter([
            'ok' => true,
            'ad_accounts' => $accounts,
            'unreachable' => $unreachable ?: null,
            'note' => 'The accounts are on screen for the user to pick. Say one short line and wait.',
        ], fn ($v) => $v !== null));
    }

    private function handleLinkedIn(Campaign $campaign): Stringable|string
    {
        $guardrails = $this->guardrails ?? app(Guardrails::class);
        $allowed = $guardrails->permittedAccounts('linkedin');
        $allowedBare = array_map(fn ($id): string => LinkedInUrn::bare((string) $id), $allowed);

        $api = $this->linkedInApi ?? app(LinkedInRestApi::class);

        try {
            $rawAccounts = $api->adAccounts();
        } catch (\Throwable $e) {
            return $this->json(['ok' => false, 'error' => $e->getMessage()]);
        }

        $accounts = [];
        foreach ($rawAccounts as $acc) {
            $id = (string) ($acc['id'] ?? '');
            if ($allowedBare !== [] && ! in_array($id, $allowedBare, true)) {
                continue;
            }

            $accounts[] = [
                'id' => $id,
                'name' => $acc['name'] ?? "LinkedIn Account {$id}",
                'currency' => $acc['currency'] ?? 'USD',
                // A status we were not given is unknown, not inactive.
                //
                // This read ($acc['status'] ?? '') === 'ACTIVE', and the finder
                // is called as rest/adAccounts?q=search with no projection, so
                // whether a status comes back at all depends on LinkedIn's
                // default. The fixture in LinkedInRestApiTest returns id, name
                // and currency and nothing else, and against that every account
                // was unusable, every account was dropped, and the question
                // never appeared. Absence of a field the request never asked
                // for is not evidence about the account.
                //
                // Being wrong in this direction costs a refusal at
                // verify_ad_account, which reads the account on its own and
                // does check. Being wrong the other way ends the conversation.
                'usable' => ! array_key_exists('status', $acc) || $acc['status'] === 'ACTIVE',
                'status' => $acc['status'] ?? null,
            ];
        }

        if (! $this->offerAccounts($accounts, $campaign, 'Which LinkedIn ad account should this campaign run on?')) {
            return $this->noneToOffer($accounts, [], 'LinkedIn');
        }

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

    /**
     * Put the accounts on screen. False when there was nothing to put there.
     *
     * Unusable accounts are shown and marked rather than dropped, so a buyer
     * who can see the account they expect is told why they cannot use it
     * instead of wondering where it went.
     *
     * @param  list<array<string,mixed>>  $accounts
     */
    private function offerAccounts(array $accounts, ?Campaign $campaign, string $question): bool
    {
        $usable = NameMatch::suggestFirst(
            array_values(array_filter($accounts, fn (array $a): bool => $a['usable'])),
            [
                ...$this->workspace->saidByUser(),
                $campaign?->landing_url,
                $this->chosenName($campaign),
            ],
        );

        $rest = array_values(array_filter($accounts, fn (array $a): bool => ! $a['usable']));

        if ($usable === [] && $rest === []) {
            return false;
        }

        $this->choices->offer(
            field: 'ad_account_id',
            question: $question,
            options: array_map(fn (array $a): array => [
                'value' => $a['id'],
                'label' => ($a['name'] ?? $a['id']).' ('.$a['id'].')',
                'hint' => $a['usable']
                    ? ($a['currency'] ?? null)
                    : trim('Not active'.(($a['status'] ?? null) ? ' ('.strtolower((string) ($a['status'] ?? '')).')' : '')),
                'badge' => ($a['suggested'] ?? false) ? 'Suggested' : null,
                'sends' => 'Use ad account '.$a['id'],
            ], [...$usable, ...$rest]),
        );

        return true;
    }

    /**
     * Say why the question is not on screen, instead of claiming it is.
     *
     * Choices::offer() ignores an empty option list, so an empty result used to
     * leave the screen unchanged while the tool still reported ok and told the
     * model the accounts were there to pick. The model said its one short line
     * and waited for a click that could never come, and the flow stopped at its
     * first step with nothing explaining why.
     *
     * The three ways of having nothing read identically from the outside and
     * need different answers, so they are separated here.
     *
     * @param  list<array<string,mixed>>  $accounts
     * @param  list<array{id: string, reason: string}>  $unreachable
     */
    private function noneToOffer(array $accounts, array $unreachable, string $platform): string
    {
        if ($accounts === [] && $unreachable !== []) {
            return $this->json([
                'ok' => false,
                'error' => sprintf('No %s ad account could be reached.', $platform),
                'unreachable' => $unreachable,
                'note' => 'Say the accounts could not be reached and give the reason. Do not ask for an account id.',
            ]);
        }

        if ($accounts === []) {
            return $this->json([
                'ok' => false,
                'error' => sprintf(
                    'None of the allowed ad accounts exist on %s, or the connected user cannot see them.',
                    $platform,
                ),
                'note' => 'Say the allowed accounts are not visible on this platform and that someone needs to '
                    .'check PLATFORM_ALLOWED_AD_ACCOUNTS. Do not ask the user for an account id.',
            ]);
        }

        return $this->json([
            'ok' => false,
            'error' => sprintf('Every allowed %s ad account is inactive.', $platform),
            'ad_accounts' => $accounts,
            'note' => 'Say which accounts were found and that none of them are active. Do not ask the user for '
                .'an account id: one they type would be inactive too.',
        ]);
    }
}
