<?php

namespace Tests\Feature;

use App\Agent\Answers;
use App\Agent\Choices;
use App\Agent\MarketingAgent;
use App\Agent\ToolRegistry;
use App\Agent\Workspace;
use App\Campaigns\CampaignPerformanceSync;
use App\Campaigns\Publishing\LinkedInPublisher;
use App\Http\Controllers\ChatController;
use App\Models\Brief;
use App\Models\Campaign;
use App\Models\CampaignAd;
use App\Models\User;
use App\Services\GoogleAdsService;
use App\Services\LinkedIn\LinkedInException;
use App\Services\LinkedIn\LinkedInRestApi;
use App\Services\LinkedIn\LinkedInUrn;
use App\Services\Platforms\LinkedInAdapter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Laravel\Ai\Tools\Request;
use PHPUnit\Framework\Attributes\DataProvider;
use RuntimeException;
use Tests\TestCase;

/**
 * Eight defects that came out of reading the stored chats and the logs.
 *
 * Counting found the frequent ones; these are the rest. Each is pinned at the
 * place the decision is actually made rather than through the chat, because the
 * chat is where they were noticed and not where they live.
 */
class EightThingsTheLogsAndChatsShowedTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();

        config([
            'platforms.meta.token' => 'test-token',
            'platforms.guardrails.allowed_ad_accounts' => ['meta:*', 'linkedin:*'],
            'platforms.guardrails.max_daily_budget' => 10_000,
        ]);
    }

    // ------------------------------------------------ 14: the wrong platform's API

    /**
     * A platform with no reader is refused, not handed to Meta.
     *
     * Every dispatch in the performance sync was `platform === 'google' ? google
     * : meta`, so Meta was the default rather than a case. Ten
     * GraphMethodExceptions in the arb-dev log are LinkedIn campaign ids queried
     * against Meta's /insights: Graph is asked about an object that was never on
     * Meta, says it does not exist, and the failure is recorded against the
     * campaign as though its numbers were unavailable.
     *
     * Traced since: campaign 28 on arb-dev is a LinkedIn campaign whose external id
     * went to Meta's /insights, and the route was closed upstream on 28 Sep by
     * filtering metaCandidates() on platform. Every failure in the log falls inside
     * an eleven-minute window that day and the successful calls come after the last
     * one, so it is fixed - by somebody else's commit, not by this.
     *
     * What this pins is the thing that was wrong either way: Meta was the fallback
     * rather than a case.
     */
    #[DataProvider('unreadablePlatforms')]
    public function test_an_unreadable_platform_does_not_reach_meta(string $platform): void
    {
        Http::fake(['*' => Http::response(['data' => []])]);

        $sync = app(CampaignPerformanceSync::class);
        $method = new \ReflectionMethod($sync, 'readerFor');

        try {
            $method->invoke($sync, ['platform' => $platform, 'external_campaign_id' => 'urn:li:sponsoredCampaign:99']);
            $this->fail("[{$platform}] was accepted, and would have been read as Meta");
        } catch (RuntimeException $e) {
            $this->assertStringContainsString('No performance reader', $e->getMessage());
            $this->assertStringContainsString('Meta is not a fallback', $e->getMessage());
        }

        Http::assertNothingSent();
    }

    /** @return list<array{0: string}> */
    public static function unreadablePlatforms(): array
    {
        return [
            'linkedin' => ['linkedin'],
            'tiktok' => ['tiktok'],
            'taboola' => ['taboola'],
            'empty' => [''],
        ];
    }

    /** The two that do have readers are still accepted. */
    public function test_meta_and_google_are_still_read(): void
    {
        $sync = app(CampaignPerformanceSync::class);
        $method = new \ReflectionMethod($sync, 'readerFor');

        foreach (['meta', 'google', 'META'] as $platform) {
            $this->assertSame(
                strtolower($platform),
                $method->invoke($sync, ['platform' => $platform, 'external_campaign_id' => '1']),
            );
        }
    }

    // ------------------------------------------- 16: a click with no acknowledgement

    /**
     * What the model is told when the user only clicks.
     *
     * A click with no typed message arrived as the single word "Continue.", so the
     * model had no idea what had been chosen: it answered a settled question, or
     * moved on without mentioning the choice, and clicking read as though nothing
     * had happened. People clicked the same option twice for that reason.
     */
    public function test_a_click_is_described_to_the_model(): void
    {
        $text = $this->clickedText([
            ['field' => 'budget_split', 'value' => 'even', 'applied' => true],
        ]);

        $this->assertStringContainsString('budget split', $text);
        $this->assertStringContainsString('even', $text);
        $this->assertStringContainsString('already recorded', $text);
    }

    /** Several answers on one screen are all named. */
    public function test_every_answer_in_a_group_is_named(): void
    {
        $text = $this->clickedText([
            ['field' => 'objective', 'value' => 'OUTCOME_LEADS', 'applied' => true],
            ['field' => 'countries', 'value' => ['US', 'CA'], 'applied' => true],
        ]);

        $this->assertStringContainsString('OUTCOME_LEADS', $text);
        $this->assertStringContainsString('US, CA', $text);
    }

    /** An answer the tool refused says so, rather than being reported as set. */
    public function test_a_refused_answer_is_not_reported_as_applied(): void
    {
        $text = $this->clickedText([
            ['field' => 'pixel_id', 'value' => '999', 'applied' => false],
        ]);

        $this->assertStringContainsString('not accepted', $text);
    }

    /** Choosing the empty option is an answer, and reads as one. */
    public function test_an_empty_choice_is_still_an_answer(): void
    {
        $this->assertStringContainsString('utm: none', $this->clickedText([
            ['field' => 'utm', 'value' => '', 'applied' => true],
        ]));
    }

    /** With nothing clicked, the old wording is unchanged. */
    public function test_no_click_still_says_continue(): void
    {
        $this->assertNull($this->clickedText([]));
    }

    /** And applying an answer now reports what it did. */
    public function test_applying_an_answer_reports_the_outcome(): void
    {
        $user = User::factory()->create();
        $this->actingAs($user);
        app(Workspace::class)->bindTo('conv-ack');
        app(Workspace::class)->ownedBy($user);

        $campaign = Campaign::create([
            'user_id' => $user->id,
            'name' => 'Ack',
            'platform' => 'meta',
            'status' => 'draft',
            'objective' => 'OUTCOME_LEADS',
        ]);
        app(Workspace::class)->focusOn($campaign);

        $applied = app(Answers::class)->applyAll([
            ['field' => 'special_ad_categories', 'value' => ['NONE']],
        ]);

        $this->assertCount(1, $applied);
        $this->assertSame('special_ad_categories', $applied[0]['field']);
        $this->assertArrayHasKey('applied', $applied[0]);
    }

    /**
     * Through promptFor, because building the sentence is not the fix.
     *
     * The fix is that the sentence reaches the model instead of "Continue.", and
     * the first version of these tests called clickedText() directly - so
     * reverting promptFor to the old one-word prompt left every one of them green.
     * The mutation found that, not the tests.
     */
    public function test_the_click_is_what_the_model_is_actually_sent(): void
    {
        $prompt = $this->promptFor(message: '', applied: [
            ['field' => 'budget_split', 'value' => 'even', 'applied' => true],
        ]);

        $this->assertNotSame('Continue.', $prompt, 'the click never reached the model');
        $this->assertStringContainsString('budget split', $prompt);
        $this->assertStringContainsString('even', $prompt);
    }

    /** A typed message is still the message; the click does not talk over it. */
    public function test_a_typed_message_wins(): void
    {
        $this->assertSame(
            'Make the headline shorter.',
            $this->promptFor(message: 'Make the headline shorter.', applied: [
                ['field' => 'objective', 'value' => 'OUTCOME_LEADS', 'applied' => true],
            ]),
        );
    }

    /** And with neither, the old wording is what goes. */
    public function test_nothing_clicked_and_nothing_typed_still_says_continue(): void
    {
        $this->assertSame('Continue.', $this->promptFor(message: '', applied: []));
    }

    /** @param list<array{field: string, value: mixed, applied: bool}> $applied */
    private function promptFor(string $message, array $applied): string
    {
        $controller = app(ChatController::class);
        $method = new \ReflectionMethod($controller, 'promptFor');

        return $method->invoke($controller, ['message' => $message], \Illuminate\Http\Request::create('/chat'), $applied);
    }

    private function clickedText(array $applied): ?string
    {
        $controller = app(ChatController::class);
        $method = new \ReflectionMethod($controller, 'clickedText');

        return $method->invoke($controller, $applied);
    }

    // --------------------------------------------- 17: Google's own reason

    /**
     * A refused mutate says what Google said, not just its enum.
     *
     * Google answers with a generic "Request contains an invalid argument." and
     * puts the real reason in details[].errors[].message with the field path. Only
     * the generic line and the error code survived, so a publish failed with
     * "Request contains an invalid argument. (DUPLICATE_CAMPAIGN_NAME)" when Google
     * had said the campaign name was a duplicate and named the field.
     */
    public function test_a_google_refusal_carries_googles_words(): void
    {
        $message = $this->googleMutationError([
            'error' => [
                'code' => 400,
                'message' => 'Request contains an invalid argument.',
                'details' => [[
                    'errors' => [[
                        'errorCode' => ['campaignError' => 'DUPLICATE_CAMPAIGN_NAME'],
                        'message' => 'The campaign name is a duplicate.',
                        'location' => ['fieldPathElements' => [['fieldName' => 'operations'], ['fieldName' => 'name']]],
                    ]],
                ]],
            ],
        ]);

        $this->assertStringContainsString('The campaign name is a duplicate', $message);
        $this->assertStringContainsString('operations.name', $message);
        $this->assertStringContainsString('DUPLICATE_CAMPAIGN_NAME', $message);
        $this->assertStringNotContainsString('Request contains an invalid argument', $message);
    }

    /** Several reasons are all reported, because a mutate can fail several ways. */
    public function test_every_reason_is_reported(): void
    {
        $message = $this->googleMutationError([
            'error' => ['message' => 'Request contains an invalid argument.', 'details' => [[
                'errors' => [
                    ['errorCode' => ['campaignError' => 'DUPLICATE_CAMPAIGN_NAME'], 'message' => 'Name is a duplicate.'],
                    ['errorCode' => ['rangeError' => 'TOO_LOW'], 'message' => 'Budget is below the minimum.'],
                ],
            ]]],
        ]);

        $this->assertStringContainsString('Name is a duplicate', $message);
        $this->assertStringContainsString('Budget is below the minimum', $message);
        $this->assertStringContainsString('TOO_LOW', $message);
    }

    /** With no per-error message, Google's top line is still better than nothing. */
    public function test_the_generic_message_is_used_when_there_is_nothing_better(): void
    {
        $message = $this->googleMutationError([
            'error' => ['message' => 'The caller does not have permission.', 'details' => []],
        ]);

        $this->assertStringContainsString('does not have permission', $message);
    }

    /** The unapproved-project case keeps its own explanation, which is actionable. */
    public function test_the_unapproved_project_case_explains_itself(): void
    {
        $message = $this->googleMutationError([
            'error' => ['message' => 'Request contains an invalid argument.', 'details' => [[
                'errors' => [[
                    'errorCode' => ['authorizationError' => 'CLOUD_PROJECT_NOT_APPROVED_FOR_PRODUCTION'],
                    'message' => 'Not approved.',
                ]],
            ]]],
        ]);

        $this->assertStringContainsString('not approved for production', $message);
        $this->assertStringContainsString('test account', $message);
    }

    /** One line, because only the first survives the trip to the chat. */
    public function test_the_message_is_one_line(): void
    {
        $message = $this->googleMutationError([
            'error' => ['message' => 'x', 'details' => [['errors' => [
                ['errorCode' => ['a' => 'B'], 'message' => "First.\nSecond."],
            ]]]],
        ]);

        $this->assertSame($message, str_replace("\n", ' ', $message));
    }

    private function googleMutationError(array $body): string
    {
        Http::fake(['*' => Http::response($body, 400)]);

        $service = app(GoogleAdsService::class);
        $method = new \ReflectionMethod($service, 'handleMutationResponse');

        try {
            $method->invoke($service, Http::get('https://googleads.googleapis.test/mutate'));
        } catch (RuntimeException $e) {
            return $e->getMessage();
        }

        $this->fail('a failed mutate did not throw');
    }

    // ------------------------------------------------ 19: the java.lang.Long error

    /**
     * A non-numeric LinkedIn id is refused before LinkedIn sees it.
     *
     * Every LinkedIn id is a number and every path is built from one, and nothing
     * checked: a Meta-style act_ id, or an account name typed instead of an id,
     * went into rest/adAccounts/{id} as it stood. LinkedIn answers "Failed to
     * convert ... to java.lang.Long", which reached the chat verbatim - a Java type
     * error in front of a media buyer, about a value they could have fixed in a
     * second if anything had said which value it was.
     */
    #[DataProvider('linkedInIds')]
    public function test_a_linkedin_id_is_a_number_or_it_is_refused(string $input, ?string $expected): void
    {
        $this->assertSame($expected, LinkedInUrn::numericId($input));
    }

    /** @return list<array{0: string, 1: ?string}> */
    public static function linkedInIds(): array
    {
        return [
            'bare number' => ['508765432', '508765432'],
            'account urn' => ['urn:li:sponsoredAccount:508765432', '508765432'],
            'campaign urn' => ['urn:li:sponsoredCampaign:99887766', '99887766'],
            'padded' => ['  508765432  ', '508765432'],
            'a meta id' => ['act_1950320145707342', null],
            'a meta id without the prefix is a number' => ['1950320145707342', '1950320145707342'],
            'an account name' => ['Acme Corp Ads', null],
            'empty' => ['', null],
            'a urn with no id' => ['urn:li:sponsoredAccount:', null],
            'letters in the id' => ['50876543a', null],
        ];
    }

    /** The transport refuses it too, so no path can be built from a bad id. */
    public function test_the_transport_refuses_a_non_numeric_account(): void
    {
        config(['platforms.linkedin.access_token' => 'test-token']);
        Http::fake(['*' => Http::response(['id' => 1])]);

        $this->expectException(LinkedInException::class);
        $this->expectExceptionMessage('not a LinkedIn ad account id');

        app(LinkedInRestApi::class)->adAccount('act_1950320145707342');
    }

    /** And nothing was sent, so LinkedIn never got the chance to answer in Java. */
    public function test_nothing_is_sent_for_a_bad_linkedin_id(): void
    {
        config(['platforms.linkedin.access_token' => 'test-token']);
        Http::fake(['*' => Http::response(['id' => 1])]);

        try {
            app(LinkedInRestApi::class)->adAccount('Acme Corp Ads');
        } catch (LinkedInException) {
            // expected
        }

        Http::assertNothingSent();
    }

    // ------------------------------- 18 and 20: stale rollback, and a second publish

    /**
     * A rollback only undoes what this attempt created.
     *
     * It walked every ad on the campaign and reset any that looked published, so
     * on a campaign that had published before, a later failed attempt cleared the
     * ids of live LinkedIn creatives it never touched. The creative kept running
     * and nothing local pointed at it: not editable, not removable, not visible as
     * ours.
     */
    public function test_a_rollback_leaves_ads_it_did_not_create_alone(): void
    {
        [$campaign, $old, $new] = $this->campaignWithTwoAds();

        app(LinkedInPublisher::class)->rollBack(
            $campaign,
            ['ads' => [['ad_row' => $new->id, 'creative_id' => 'urn:li:sponsoredCreative:222']]],
            'test',
        );

        $this->assertSame(
            'urn:li:sponsoredCreative:111',
            $old->fresh()->external_creative_id,
            'a live creative from an earlier publish had its id cleared',
        );
        $this->assertSame('published', $old->fresh()->status);
        $this->assertNull($new->fresh()->external_creative_id, 'the ad this attempt created was not reset');
    }

    /** An ad that already has a creative is not given a second one. */
    public function test_a_second_publish_does_not_duplicate_a_live_creative(): void
    {
        // The newer ad has no creative yet, which is the case a second publish is
        // for: one ad live from before, one waiting to be built.
        [$campaign, $old, $new] = $this->campaignWithTwoAds(secondLive: false);

        $asked = [];

        $adapter = \Mockery::mock(LinkedInAdapter::class);
        $adapter->shouldReceive('createCampaign')->andReturn('urn:li:sponsoredCampaign:1');
        $adapter->shouldReceive('publish')->andReturnNull();
        $adapter->shouldReceive('createAd')
            ->andReturnUsing(function ($campaign, CampaignAd $ad) use (&$asked): string {
                $asked[] = $ad->id;

                return 'urn:li:sponsoredCreative:new'.$ad->id;
            });

        $created = [];
        (new LinkedInPublisher($adapter))->create($campaign, 'test', $created);

        $this->assertSame([$new->id], $asked, 'a creative was built again for an ad that already had one');
        $this->assertSame(
            'urn:li:sponsoredCreative:111',
            $old->fresh()->external_creative_id,
            'the existing creative id was overwritten',
        );
    }

    /**
     * A campaign with one ad already live and one added since.
     *
     * $secondLive says whether the newer ad has a creative on LinkedIn yet, which
     * is the difference between the two cases: a rollback has to leave the older
     * one alone, and a second publish has to build only the one that has none.
     *
     * @return array{0: Campaign, 1: CampaignAd, 2: CampaignAd}
     */
    private function campaignWithTwoAds(bool $secondLive = true): array
    {
        $user = User::factory()->create();

        $campaign = Campaign::create([
            'user_id' => $user->id,
            'name' => 'LinkedIn campaign',
            'platform' => 'linkedin',
            'status' => 'published',
            'ad_account_id' => '508765432',
            'external_campaign_id' => 'urn:li:sponsoredCampaign:1',
        ]);

        $old = CampaignAd::create([
            'campaign_id' => $campaign->id,
            'headline' => 'Already live',
            'primary_text' => 'Copy that was published earlier.',
            'cta' => 'LEARN_MORE',
            'status' => 'published',
            'external_creative_id' => 'urn:li:sponsoredCreative:111',
            'external_ad_id' => 'urn:li:sponsoredCreative:111',
        ]);

        $new = CampaignAd::create([
            'campaign_id' => $campaign->id,
            'headline' => 'Added later',
            'primary_text' => 'Copy added in a later turn.',
            'cta' => 'LEARN_MORE',
            'status' => $secondLive ? 'published' : 'draft',
            ...$secondLive ? [
                'external_creative_id' => 'urn:li:sponsoredCreative:222',
                'external_ad_id' => 'urn:li:sponsoredCreative:222',
            ] : [],
        ]);

        return [$campaign, $old, $new];
    }

    // -------------------------------------------- 21: split_budget with no total

    /**
     * A split with no total says which tool records one.
     *
     * All three refusals here said what was wrong and not what to do, so the model
     * reported the refusal to the buyer as a dead end. "Ask for the total budget
     * first" in particular reads as an instruction to the user, and the turn ended
     * with a question nobody could usefully answer.
     */
    public function test_a_split_with_no_total_names_the_tool_that_sets_one(): void
    {
        $result = $this->splitOn(budget: 0);

        $this->assertFalse($result['ok']);
        $this->assertStringContainsString('set_campaign_budget', $result['note']);
        $this->assertSame(['meta', 'google'], $result['platforms']);
    }

    /** And it asks for one total rather than an amount per platform. */
    public function test_the_note_asks_for_a_single_total(): void
    {
        $this->assertStringContainsString('one total', $this->splitOn(budget: 0)['note']);
    }

    /** The error line itself stays about the state, not about tool names. */
    public function test_the_buyer_facing_line_does_not_name_a_tool(): void
    {
        $this->assertStringNotContainsString('set_campaign_budget', $this->splitOn(budget: 0)['error']);
    }

    private function splitOn(float $budget): array
    {
        MarketingAgent::fake(['Noted.']);

        $user = User::factory()->create();
        $this->actingAs($user);

        $workspace = app(Workspace::class);
        $workspace->bindTo('conv-split-none');
        $workspace->ownedBy($user);

        $brief = Brief::create([
            'user_id' => $user->id,
            'conversation_id' => 'conv-split-none',
            'budget' => $budget,
            'budget_mode' => 'daily',
            'currency' => 'USD',
        ]);

        $campaigns = collect(['meta', 'google'])->map(fn (string $platform): Campaign => Campaign::create([
            'user_id' => $user->id,
            'brief_id' => $brief->id,
            'name' => ucfirst($platform),
            'platform' => $platform,
            'status' => 'draft',
        ]));

        $workspace->focusOn($campaigns->first()->fresh());

        $result = collect(app(ToolRegistry::class)->resolve())
            ->first(fn ($t) => $t->name() === 'campaign__split_budget')
            ->handle(new Request);

        return json_decode((string) $result, true);
    }

    /** Sanity: nothing above accidentally emptied the offer screen. */
    public function test_the_offer_screen_still_works_with_a_total(): void
    {
        config(['platforms.guardrails.max_daily_budget' => 1000]);

        $this->assertTrue($this->splitOn(budget: 50)['ok']);
        $this->assertNotNull(collect(app(Choices::class)->all())->firstWhere('field', 'budget_split'));
    }
}
