<?php

namespace Tests\Feature;

use App\Campaigns\Platforms\LinkedInRules;
use App\Campaigns\Publishing\LinkedInPublisher;
use App\Models\Campaign;
use App\Models\User;
use App\Services\LinkedIn\LinkedInException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

/**
 * An edit to a published campaign reaches LinkedIn.
 *
 * update() was an empty method body under the comment "Live updates for
 * LinkedIn". CampaignUpdater moves its snapshot forward on a clean return, so
 * every edit was recorded as applied and none was sent: the budget, the bid and
 * the dates on LinkedIn stayed as first published while our rows and the chat
 * said they had changed. A buyer raising a daily budget saw the new number
 * everywhere except where the money is spent.
 *
 * Nothing failed, which is why it survived. An empty method is indistinguishable
 * from a successful one to everything upstream of it.
 */
class LinkedInEditsReachTheLiveCampaignTest 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([], 200)]);
    }

    private function published(array $attributes = []): Campaign
    {
        return Campaign::create([
            'user_id' => User::factory()->create()->id,
            'name' => 'Q4 launch',
            'platform' => 'linkedin',
            'status' => 'published',
            'objective' => 'WEBSITE_VISITS',
            'ad_account_id' => '556447014',
            'external_campaign_id' => 'urn:li:sponsoredCampaign:77',
            'budget' => 50,
            'budget_mode' => 'daily',
            'locations' => ['countries' => ['US']],
            ...$attributes,
        ]);
    }

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

        return [];
    }

    private function apply(Campaign $campaign, array $changes): void
    {
        app(LinkedInPublisher::class)->update($campaign, $changes, 'tester');
    }

    /** The defect: a budget change that never left the building. */
    public function test_a_new_budget_is_sent_to_linkedin(): void
    {
        $campaign = $this->published(['budget' => 80]);

        $this->apply($campaign, ['budget' => ['from' => 50, 'to' => 80]]);

        $this->assertSame('80.00', $this->sentPatch()['dailyBudget']['amount'] ?? null);
    }

    /** Something has to actually go over the wire, not just be shaped. */
    public function test_an_edit_sends_a_request_at_all(): void
    {
        $campaign = $this->published(['budget' => 80]);

        $this->apply($campaign, ['budget' => ['from' => 50, 'to' => 80]]);

        Http::assertSent(fn (Request $r): bool => str_contains($r->url(), 'adCampaigns/77'));
    }

    /** A renamed campaign keeps the prefix LinkedIn already knows it by. */
    public function test_a_rename_keeps_the_prefix_create_used(): void
    {
        $campaign = $this->published(['name' => 'Q1 launch']);

        $this->apply($campaign, ['name' => ['from' => 'Q4 launch', 'to' => 'Q1 launch']]);

        $this->assertSame('ARB-Q1 launch', $this->sentPatch()['name'] ?? null);
    }

    /** A new end date goes, and carries the start with it because LinkedIn wants both. */
    public function test_a_new_end_date_is_sent(): void
    {
        $ends = now()->addDays(20);
        $campaign = $this->published(['ends_at' => $ends]);

        $this->apply($campaign, ['ends_at' => ['from' => null, 'to' => $ends]]);

        $this->assertSame((int) ($ends->timestamp * 1000), $this->sentPatch()['runSchedule']['end'] ?? null);
    }

    /**
     * The dates are left alone when no date changed.
     *
     * runSchedule carries the start as well as the end, and LinkedIn refuses to
     * move the start of a campaign that is already running. Sending it on a
     * budget edit would fail the whole patch and take the budget down with it.
     */
    public function test_a_budget_edit_does_not_resend_the_dates(): void
    {
        $campaign = $this->published(['budget' => 80, 'ends_at' => now()->addDays(20)]);

        $this->apply($campaign, ['budget' => ['from' => 50, 'to' => 80]]);

        $this->assertArrayNotHasKey('runSchedule', $this->sentPatch());
    }

    /** A new bid goes without costType, which LinkedIn fixes at launch. */
    public function test_a_bid_change_does_not_try_to_change_how_it_is_charged(): void
    {
        $campaign = $this->published(['bid_strategy' => 'MAXIMUM_BID', 'bid_amount' => 6]);

        $this->apply($campaign, ['bid_amount' => ['from' => 4, 'to' => 6]]);

        $patch = $this->sentPatch();

        $this->assertSame('6.00', $patch['unitCost']['amount'] ?? null);
        $this->assertArrayNotHasKey('costType', $patch);
    }

    /**
     * Every field the rules call editable is handled here.
     *
     * The two lists drifting apart is the original bug in slower motion: a
     * field declared editable and quietly not sent leaves the campaign and our
     * record of it disagreeing, with nothing raised.
     */
    public function test_every_field_declared_editable_is_actually_sent(): void
    {
        foreach ((new LinkedInRules)->editableAfterPublish() as $field) {
            $campaign = $this->published(['bid_strategy' => 'MAXIMUM_BID', 'bid_amount' => 6]);

            $this->apply($campaign, [$field => ['from' => null, 'to' => 'x']]);

            $this->assertNotSame([], $this->sentPatch(), "{$field} is editable but sent nothing");

            Http::fake(['https://api.linkedin.com/*' => Http::response([], 200)]);
        }
    }

    /** A field nothing can change is refused rather than silently dropped. */
    public function test_an_unsupported_field_is_refused(): void
    {
        $campaign = $this->published();

        $this->expectException(LinkedInException::class);
        $this->expectExceptionMessageMatches('/objective/');

        $this->apply($campaign, ['objective' => ['from' => 'A', 'to' => 'B']]);
    }

    /** A campaign that was never published has nothing live to edit. */
    public function test_an_unpublished_campaign_is_refused(): void
    {
        $campaign = $this->published(['external_campaign_id' => null, 'status' => 'draft']);

        $this->expectException(LinkedInException::class);

        $this->apply($campaign, ['budget' => ['from' => 50, 'to' => 80]]);
    }

    /** No changes means no request, rather than an empty patch. */
    public function test_nothing_is_sent_when_nothing_changed(): void
    {
        $this->apply($this->published(), []);

        Http::assertNothingSent();
    }
}
