<?php

namespace Tests\Feature;

use App\Models\Campaign;
use App\Models\Creative;
use App\Models\User;
use App\Services\LinkedIn\LinkedInException;
use App\Services\Platforms\LinkedInAdapter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

/**
 * What the buyer chose is what LinkedIn is told.
 *
 * The adapter had four values written into it rather than read off the
 * campaign: CPM for every objective, no bid amount at all, en_US for every
 * country, and WEBSITE_VISIT for any objective it did not recognise. Each one
 * publishes. None of them fails. The campaign simply runs as something other
 * than the thing that was agreed, and the buyer has no way to see it from
 * inside the chat, because the chat reports what was chosen rather than what
 * was sent.
 *
 * That last part is why these are asserted against the request body. A test
 * that asks the adapter what it would do reproduces the adapter's own belief.
 */
class LinkedInSendsTheCampaignItWasGivenTest extends TestCase
{
    use RefreshDatabase;

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

        config([
            'platforms.linkedin.token' => 'test-token',
            'platforms.linkedin.publish_enabled' => false,
            'platforms.guardrails.allowed_ad_accounts' => ['556447014'],
        ]);

        Http::fake(['https://api.linkedin.com/*' => Http::response([], 201, ['x-restli-id' => 'urn:li:sponsoredCampaign:1'])]);
    }

    private function campaign(array $attributes = []): Campaign
    {
        return Campaign::create([
            'user_id' => User::factory()->create()->id,
            'name' => 'Q4 launch',
            'platform' => 'linkedin',
            'status' => 'draft',
            'objective' => 'WEBSITE_VISITS',
            'ad_account_id' => '556447014',
            'budget' => 50,
            'landing_url' => 'https://example.com',
            'locations' => ['countries' => ['US']],
            ...$attributes,
        ]);
    }

    /** @return array<string, mixed> the body that went to LinkedIn */
    private function sent(string $contains): array
    {
        foreach (Http::recorded() as [$request]) {
            /** @var Request $request */
            if (str_contains($request->url(), $contains)) {
                return $request->data();
            }
        }

        return [];
    }

    private function create(Campaign $campaign): void
    {
        app(LinkedInAdapter::class)->createCampaign($campaign);
    }

    /**
     * The defect: every campaign was bought on impressions.
     *
     * costType was the literal 'CPM'. A traffic campaign told to optimise for
     * clicks was billed per thousand impressions, which spends the budget on
     * reach and reports it as traffic.
     */
    public function test_a_traffic_campaign_is_bought_on_clicks(): void
    {
        $this->create($this->campaign(['objective' => 'WEBSITE_VISITS']));

        $this->assertSame('CPC', $this->sent('adCampaigns')['costType'] ?? null);
    }

    /** And awareness, where impressions are the point, still buys impressions. */
    public function test_an_awareness_campaign_is_still_bought_on_impressions(): void
    {
        $this->create($this->campaign(['objective' => 'BRAND_AWARENESS']));

        $this->assertSame('CPM', $this->sent('adCampaigns')['costType'] ?? null);
    }

    /**
     * The bid the buyer named is sent, in the currency they named.
     *
     * Nothing carried bid_amount at all, so a maximum bid of $4 was chosen in
     * the chat, stored, confirmed back, and then never sent. LinkedIn bid
     * automatically and the cap the buyer set did not exist.
     */
    public function test_a_maximum_bid_reaches_linkedin(): void
    {
        $this->create($this->campaign([
            'bid_strategy' => 'MAXIMUM_BID',
            'bid_amount' => 4.50,
            'currency' => 'EUR',
        ]));

        $body = $this->sent('adCampaigns');

        $this->assertSame('4.50', $body['unitCost']['amount'] ?? null);
        $this->assertSame('EUR', $body['unitCost']['currencyCode'] ?? null);

        // Not bidStrategy. This asserted it and passed, because Http::fake
        // takes any field; the live API answers "/bidStrategy :: unrecognized
        // field found but not allowed" and refuses the campaign. A cap is
        // expressed by sending unitCost at all, which is asserted above.
        $this->assertArrayNotHasKey('bidStrategy', $body);
    }

    /** A bid cap with no number is refused rather than silently dropped. */
    public function test_a_bid_strategy_with_no_amount_is_refused(): void
    {
        $this->expectException(LinkedInException::class);

        $this->create($this->campaign(['bid_strategy' => 'MAXIMUM_BID', 'bid_amount' => null]));
    }

    /** Automated bidding needs no number, so it sends none. */
    public function test_automated_bidding_sends_no_amount(): void
    {
        $this->create($this->campaign(['bid_strategy' => 'AUTOMATED_BID']));

        $this->assertArrayNotHasKey('unitCost', $this->sent('adCampaigns'));
    }

    /**
     * Targeting a country means targeting the people who read its language.
     *
     * The locale was the literal en_US list. A German campaign reached only
     * LinkedIn members in Germany whose interface language is English, which is
     * a small fraction of the audience that was paid for, and the delivery
     * looks like a bad audience rather than a bad request.
     */
    public function test_a_german_campaign_reaches_german_speakers(): void
    {
        $this->create($this->campaign(['locations' => ['countries' => ['DE']]]));

        $locales = json_encode($this->sent('adCampaigns')['targetingCriteria'] ?? []);

        $this->assertStringContainsString('urn:li:locale:de_DE', $locales);
    }

    /** English stays, because it is read everywhere and dropping it narrows reach. */
    public function test_english_is_kept_alongside_the_local_language(): void
    {
        $this->create($this->campaign(['locations' => ['countries' => ['DE']]]));

        $locales = json_encode($this->sent('adCampaigns')['targetingCriteria'] ?? []);

        $this->assertStringContainsString('urn:li:locale:en_US', $locales);
    }

    /**
     * An objective LinkedIn does not take is refused, not renamed.
     *
     * The match had `default => 'WEBSITE_VISIT'`. A lead-generation campaign
     * whose objective did not match the arm published as a traffic campaign:
     * live, spending, and optimising for the wrong thing, with no error
     * anywhere to look at.
     */
    public function test_an_unmapped_objective_is_refused_rather_than_becoming_traffic(): void
    {
        $this->expectException(LinkedInException::class);

        $this->create($this->campaign(['objective' => 'APP_INSTALLS']));
    }

    /** A lifetime budget goes as a total with an end date, not as a daily spend. */
    public function test_a_lifetime_budget_is_not_sent_as_a_daily_budget(): void
    {
        $this->create($this->campaign([
            'budget_mode' => 'lifetime',
            'budget' => 500,
            'ends_at' => now()->addDays(30),
        ]));

        $body = $this->sent('adCampaigns');

        $this->assertArrayNotHasKey('dailyBudget', $body);
        $this->assertSame('500.00', $body['totalBudget']['amount'] ?? null);
    }

    /**
     * A daily budget keeps its end date too.
     *
     * runSchedule was built only in the lifetime branch, so a campaign with a
     * daily budget and an end date was sent without one and ran past it until
     * somebody noticed. Nothing fails; it just keeps spending, which is the
     * expensive way for a bug to behave.
     */
    public function test_a_daily_budget_still_sends_its_end_date(): void
    {
        $ends = now()->addDays(10);

        $this->create($this->campaign(['budget_mode' => 'daily', 'ends_at' => $ends]));

        $body = $this->sent('adCampaigns');

        $this->assertSame('50.00', $body['dailyBudget']['amount'] ?? null, 'daily budget still sent');
        $this->assertSame((int) ($ends->timestamp * 1000), $body['runSchedule']['end'] ?? null);
    }

    /** A daily budget with no end date sends a start and no end, not a null one. */
    public function test_a_campaign_with_no_end_date_sends_no_end_key(): void
    {
        $this->create($this->campaign(['budget_mode' => 'daily', 'ends_at' => null]));

        $schedule = $this->sent('adCampaigns')['runSchedule'] ?? [];

        $this->assertArrayHasKey('start', $schedule);
        $this->assertArrayNotHasKey('end', $schedule);
    }

    /** And a lifetime budget with no end date is refused, because it has no meaning. */
    public function test_a_lifetime_budget_with_no_end_date_is_refused(): void
    {
        $this->expectException(LinkedInException::class);

        $this->create($this->campaign(['budget_mode' => 'lifetime', 'ends_at' => null]));
    }

    /** attachCreative refuses fabricated ID when creative has no asset or post. */
    public function test_attach_creative_refuses_when_no_valid_asset_exists(): void
    {
        $campaign = $this->campaign(['external_campaign_id' => '123456']);
        $creative = Creative::create([
            'user_id' => $campaign->user_id,
            'type' => 'image',
            'status' => 'ready',
            'source' => 'prompt',
            'title' => 'Test Creative',
        ]);

        $adapter = app(LinkedInAdapter::class);

        $this->expectException(LinkedInException::class);
        $this->expectExceptionMessageMatches('/has no valid LinkedIn post or asset to sponsor/');

        $adapter->attachCreative($campaign, $creative);
    }

    /**
     * The three fields LinkedIn requires and this never sent.
     *
     * Every campaign create came back 422 listing them, so no LinkedIn campaign
     * could be created at all. Found by posting the adapter's own payload at the
     * live API and reading what it asked for; the request never got far enough
     * for anything in the product to report them.
     */
    public function test_the_required_fields_linkedin_asks_for_are_sent(): void
    {
        $this->create($this->campaign());

        $body = $this->sent('adCampaigns');

        $this->assertSame(['country' => 'US', 'language' => 'en'], $body['locale'] ?? null);
        $this->assertSame('NOT_POLITICAL', $body['politicalIntent'] ?? null);
        $this->assertArrayHasKey('offsiteDeliveryEnabled', $body);
    }

    /**
     * offsiteDeliveryEnabled survives being false.
     *
     * It is required, it is usually false, and the payload was built with
     * array_filter(), which strips exactly that. Sent and silently removed.
     */
    public function test_offsite_delivery_is_sent_even_when_false(): void
    {
        $this->create($this->campaign(['placements' => ['linkedin_feed']]));

        $this->assertFalse($this->sent('adCampaigns')['offsiteDeliveryEnabled'] ?? null);
    }

    /** And is true when the audience network is actually chosen. */
    public function test_offsite_delivery_is_true_for_the_audience_network(): void
    {
        $this->create($this->campaign(['placements' => ['linkedin_feed', 'linkedin_audience_network']]));

        $this->assertTrue($this->sent('adCampaigns')['offsiteDeliveryEnabled'] ?? null);
    }

    /** The campaign locale follows the country being targeted. */
    public function test_the_campaign_locale_follows_the_country(): void
    {
        $this->create($this->campaign(['locations' => ['countries' => ['DE']]]));

        $this->assertSame(['country' => 'DE', 'language' => 'de'], $this->sent('adCampaigns')['locale'] ?? null);
    }
}
