<?php

namespace Tests\Feature;

use App\Models\Asset;
use App\Models\Campaign;
use App\Models\User;
use App\Services\LinkedIn\LinkedIn;
use App\Services\LinkedIn\LinkedInRestApi;
use App\Services\Platforms\LinkedInAdapter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Tests\SeedsVerifiedCountries;
use Tests\TestCase;

/**
 * Nothing LinkedIn builds is live when it is built.
 *
 * This used to be about a switch. LINKEDIN_PUBLISH_ENABLED rewrote statuses on
 * the way out: the adapter asked for ACTIVE, believed it had got it, and the
 * transport quietly disagreed. The rewrite covered `status` and, for a while, only
 * `status` - so a creative went out ACTIVE on its own `intendedStatus` key and a
 * sponsored post went out PUBLISHED and PUBLIC on the company Page while the
 * campaign above it sat correctly paused.
 *
 * The switch is gone. Building paused is now what the adapter asks for, and
 * starting a campaign is a separate, separately approved act with its own tool. So
 * these assert the payloads the adapter builds rather than the corrections a
 * transport applied to them - which is the same guarantee, checked where it is now
 * actually made.
 *
 * Each level separately, because the reason the old switch failed is that one
 * shared helper was assumed to cover three different keys.
 */
class LinkedInBuildsEveryLevelPausedTest extends TestCase
{
    use RefreshDatabase;
    use SeedsVerifiedCountries;

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

        $this->seedVerifiedCountries();

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

        // Not faked here. Stubs match in the order they are registered, so a
        // catch-all in setUp answers the image upload before the specific patterns
        // the creative path needs are ever consulted - which is what made the
        // adapter tests below report a failed upload.
    }

    /** The catch-all the transport tests want, and nothing more. */
    private function fakeLinkedIn(): void
    {
        Http::fake(['https://api.linkedin.com/*' => Http::response([], 201, ['x-restli-id' => 'urn:li:thing:1'])]);
    }

    /**
     * The body the adapter built for one of LinkedIn's endpoints.
     *
     * Through the adapter rather than the transport, because the adapter is where
     * "build it paused" is now decided. Driving the transport directly, which is
     * what these tests used to do, could only ever show what the rewrite did.
     *
     * @return array<string, mixed>
     */
    private function adapterPayload(string $contains): array
    {
        $user = User::factory()->create();

        $campaign = Campaign::create([
            'user_id' => $user->id,
            'name' => 'Winter Sale',
            'platform' => 'linkedin',
            'status' => 'draft',
            'objective' => 'WEBSITE_VISITS',
            'ad_account_id' => '556447014',
            'landing_url' => 'https://example.com/offer',
            'budget' => 25,
            'budget_mode' => 'daily',
            'currency' => 'USD',
            // A capped strategy with its amount, because MAXIMUM_DELIVERY is
            // refused without one and the campaign build never gets started.
            'bid_strategy' => 'COST_CAP',
            'bid_amount' => 5,
        ]);

        $asset = Asset::create([
            'owner_type' => $user->getMorphClass(), 'owner_id' => $user->id,
            'kind' => 'image', 'source' => 'generated', 'disk' => 'public',
            'path' => 'creatives/x.png', 'original_name' => 'x.png',
            'mime' => 'image/png', 'bytes' => 10, 'checksum' => 'sum',
        ]);

        $ad = $campaign->ads()->create([
            'headline' => 'Winter Sale',
            'primary_text' => 'Everything reduced.',
            'cta' => 'SHOP_NOW',
            'asset_id' => $asset->id,
        ]);

        config(['platforms.linkedin.organization_id' => '106065219']);

        // The image upload is two calls with shapes of their own, and the creative
        // is only reached once they have both answered - so they get real stubs
        // rather than the catch-all, or the chain stops before the payloads under
        // test are built at all.
        Storage::fake('public');
        Storage::disk('public')->put('creatives/x.png', 'bytes');

        // A fresh client, because stubs are matched in the order they were
        // registered and setUp's catch-all for api.linkedin.com would answer the
        // upload calls before the specific patterns below ever got a look.
        Http::fake([
            '*initializeUpload*' => Http::response([
                'value' => ['uploadUrl' => 'https://upload.linkedin.test/x', 'image' => 'urn:li:image:C123'],
            ], 200),
            'https://upload.linkedin.test/*' => Http::response([], 201),
            'https://api.linkedin.com/*' => Http::response([], 201, ['x-restli-id' => 'urn:li:thing:1']),
        ]);

        $adapter = app(LinkedInAdapter::class);

        // Both trees: the campaign and its group, then the creative, with the
        // sponsored post built inside the second.
        //
        // In this order, and the id written only after. createCampaign() returns
        // early when external_campaign_id is already set - which is right, it is
        // what stops a second publish minting a second campaign - so setting it
        // first meant no campaign was ever built and its payload was never sent.
        $campaignId = $adapter->createCampaign($campaign->fresh());
        $campaign->update(['external_campaign_id' => $campaignId]);

        $adapter->createAd($campaign->fresh(), $ad);

        return $this->sentBody($contains);
    }

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

        return [];
    }

    /**
     * DRAFT, and PAUSED would be rejected outright.
     *
     * This asserted PAUSED and passed, because Http::fake accepts anything. The
     * real API does not: "/CampaignGroup/status cannot be changed from null to
     * PAUSED", because a group is created DRAFT or ACTIVE and only moves to
     * PAUSED afterwards. Every LinkedIn publish failed on it, and only driving
     * arb-dev end to end showed it.
     *
     * DRAFT is the stricter of the two anyway: a paused group can be resumed, a
     * draft one has never been live.
     */
    public function test_a_campaign_group_is_created_as_a_draft(): void
    {
        $this->fakeLinkedIn();
        app(LinkedInRestApi::class)->createCampaignGroup('556447014', ['name' => 'Q4']);

        $this->assertSame('DRAFT', $this->sentBody('adCampaignGroups')['status'] ?? null);
    }

    /** Never PAUSED on creation, which is the shape LinkedIn refuses. */
    public function test_a_campaign_group_is_never_created_paused(): void
    {
        $this->fakeLinkedIn();
        app(LinkedInRestApi::class)->createCampaignGroup('556447014', ['name' => 'Q4']);

        $this->assertNotSame('PAUSED', $this->sentBody('adCampaignGroups')['status'] ?? null);
    }

    /**
     * The transport sends what it is given, now that nothing rewrites.
     *
     * This asked createCampaignGroup for ACTIVE and asserted DRAFT came out, which
     * pinned the rewrite. The rewrite was the defect: a caller that asks for ACTIVE
     * and gets DRAFT has no way to know, and the adapter above did exactly that for
     * months. What matters now is that the adapter does not ask.
     */
    public function test_the_transport_no_longer_edits_what_it_is_given(): void
    {
        $this->fakeLinkedIn();
        app(LinkedInRestApi::class)->createCampaignGroup('556447014', ['name' => 'Q4', 'status' => 'ACTIVE']);

        $this->assertSame(
            'ACTIVE',
            $this->sentBody('adCampaignGroups')['status'] ?? null,
            'the transport is still second-guessing its caller',
        );
    }

    /** And the campaign the adapter builds is paused. */
    public function test_the_adapter_builds_a_paused_campaign(): void
    {
        $this->assertSame('PAUSED', $this->adapterPayload('adCampaigns')['status'] ?? null);
    }

    /** The creative's own switch is intendedStatus, and the adapter sets it DRAFT. */
    public function test_the_adapter_builds_a_draft_creative(): void
    {
        $this->assertSame('DRAFT', $this->adapterPayload('creatives')['intendedStatus'] ?? null);
    }

    /**
     * The post is the one that reaches the public.
     *
     * lifecycleState PUBLISHED with visibility PUBLIC puts it on the company Page
     * under its own name. The adapter asked for PUBLISHED and relied on being
     * corrected; for a period the correction did not cover this key at all, and
     * real posts went out.
     */
    public function test_the_adapter_builds_a_draft_post(): void
    {
        $body = $this->adapterPayload('rest/posts');

        $this->assertSame('DRAFT', $body['lifecycleState'] ?? null);

        // dscStatus is left ACTIVE on purpose. LinkedIn accepts no other value when
        // creating Direct Sponsored Content: DRAFT and PAUSED are not enum symbols
        // and ARCHIVED is refused outright, so forcing it makes the post impossible
        // to create rather than safe. lifecycleState above is what keeps it off the
        // Page.
        $this->assertSame('ACTIVE', $body['adContext']['dscStatus'] ?? null);
    }

    /**
     * Turning publishing on must not break publishing.
     *
     * The facade defaulted the campaign group to PAUSED and the transport sets
     * DRAFT then spreads the payload over it, so PAUSED won. With the kill
     * switch on, ensureSafeStatus() forced it back to DRAFT and everything
     * worked; with the switch off it returned early and PAUSED went out, which
     * LinkedIn refuses outright. So LinkedIn could publish only while
     * publishing was disabled, and the existing tests in this file all run
     * through LinkedInRestApi directly with no status key, never through the
     * facade the publisher actually uses.
     */
    public function test_the_campaign_group_is_created_draft_with_publishing_enabled(): void
    {
        config(['platforms.linkedin.publish_enabled' => true]);

        Http::fake([
            '*' => Http::response(['id' => '999'], 201),
        ]);

        app(LinkedIn::class)->createCampaignGroup('urn:li:sponsoredAccount:556447014', 'Winter Sale Group');

        Http::assertSent(function ($request): bool {
            if (! str_contains($request->url(), 'adCampaignGroups')) {
                return false;
            }

            $status = $request->data()['status'] ?? null;

            $this->assertNotSame('PAUSED', $status, 'LinkedIn refuses PAUSED on creation');
            $this->assertSame('DRAFT', $status);

            return true;
        });
    }
}
