<?php

namespace Tests\Feature;

use App\Models\Campaign;
use App\Models\CampaignPerformanceHourStat;
use App\Models\CampaignPerformanceStat;
use App\Models\CampaignPerformanceSuggestion;
use App\Models\GoogleAdsCampaign;
use App\Models\GoogleAdsConnection;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;

class CampaignPerformanceSyncTest extends TestCase
{
    use RefreshDatabase;

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

        $this->travelTo('2026-09-22 12:00:00');

        config([
            'campaign-performance.platforms' => ['meta'],
            'campaign-performance.external_ids' => [],
            'campaign-performance.limit' => 5,
            'campaign-performance.backfill_days' => 2,
            'campaign-performance.daily_lookback_hours' => 24,
            'campaign-performance.llm_enabled' => false,
            'platforms.meta.token' => 'test-token',
            'platforms.meta.version' => 'v23.0',
            'platforms.guardrails.retry_backoff_ms' => 0,
            'platforms.guardrails.allowed_ad_accounts' => ['act_111', '1234567890'],
        ]);
    }

    public function test_it_dumps_meta_insights_spend_not_the_local_budget(): void
    {
        $campaign = $this->liveMetaCampaign('1201', ['budget' => 99.00]);

        $this->fakeMetaInsights('1201', [
            ['date_start' => '2026-09-21', 'spend' => '12.10', 'impressions' => '1000', 'clicks' => '10'],
            ['date_start' => '2026-09-22', 'spend' => '8.00', 'impressions' => '400', 'clicks' => '2'],
        ]);

        $this->artisan('campaigns:sync-performance', ['--dump' => true])->assertSuccessful();

        $row = CampaignPerformanceStat::query()->whereDate('stat_date', '2026-09-21')->first();
        $this->assertNotNull($row);
        $this->assertSame('meta', $row->platform);
        $this->assertSame('1201', $row->external_campaign_id);
        $this->assertSame($campaign->id, $row->local_campaign_id);
        $this->assertSame(12.1, (float) $row->spend);
        $this->assertSame(1000, (int) $row->impressions);
        $this->assertSame(10, (int) $row->clicks);
        $this->assertNotSame(99.0, (float) $row->spend);
        $this->assertSame('PAUSED', $row->platform_status);
        $this->assertSame(2, CampaignPerformanceStat::query()->count());
    }

    public function test_it_does_not_dump_google_campaigns_when_platforms_is_meta_only(): void
    {
        $this->liveMetaCampaign('1201');
        $this->liveGoogleCampaign('222');
        $this->fakeMetaInsights('1201', [
            ['date_start' => '2026-09-22', 'spend' => '1.00', 'impressions' => '10', 'clicks' => '1'],
        ]);

        $this->artisan('campaigns:sync-performance', ['--dump' => true])->assertSuccessful();

        $this->assertSame(0, CampaignPerformanceStat::query()->where('platform', 'google')->count());
        $this->assertSame(1, CampaignPerformanceStat::query()->where('platform', 'meta')->count());
    }

    public function test_it_converts_google_cost_micros_to_major_units(): void
    {
        config(['campaign-performance.platforms' => ['google']]);

        $campaign = $this->liveGoogleCampaign('222');
        $this->fakeGoogleMetrics('222', '1500000');

        $this->artisan('campaigns:sync-performance', ['--dump' => true])->assertSuccessful();

        $row = CampaignPerformanceStat::query()->first();
        $this->assertNotNull($row);
        $this->assertSame('google', $row->platform);
        $this->assertSame('222', $row->external_campaign_id);
        $this->assertSame($campaign->id, $row->local_google_ads_campaign_id);
        $this->assertSame(1.5, (float) $row->spend);
        $this->assertSame(100, (int) $row->impressions);
        $this->assertSame(4, (int) $row->clicks);
    }

    public function test_it_skips_google_drafts_without_a_platform_id(): void
    {
        config(['campaign-performance.platforms' => ['google']]);

        GoogleAdsCampaign::create([
            'user_id' => User::factory()->create()->id,
            'name' => 'Local draft',
            'status' => 'draft',
        ]);

        Http::fake();

        $this->artisan('campaigns:sync-performance', ['--dump' => true])->assertSuccessful();

        $this->assertSame(0, CampaignPerformanceStat::query()->count());
    }

    public function test_it_respects_the_campaign_limit(): void
    {
        config(['campaign-performance.limit' => 1]);

        $older = $this->liveMetaCampaign('1001');
        $older->forceFill(['created_at' => now()->subDay()])->save();
        $this->liveMetaCampaign('1002');

        Http::fake([
            '*/1001/insights*' => Http::response(['data' => [
                ['date_start' => '2026-09-22', 'spend' => '1.00', 'impressions' => '5', 'clicks' => '1'],
            ]]),
            '*/1001*' => Http::response(['id' => '1001', 'status' => 'ACTIVE', 'effective_status' => 'ACTIVE']),
            '*/1002/insights*' => Http::response(['data' => [
                ['date_start' => '2026-09-22', 'spend' => '9.00', 'impressions' => '5', 'clicks' => '1'],
            ]]),
            '*/1002*' => Http::response(['id' => '1002', 'status' => 'ACTIVE', 'effective_status' => 'ACTIVE']),
        ]);

        $this->artisan('campaigns:sync-performance', ['--dump' => true])->assertSuccessful();

        $this->assertEqualsCanonicalizing(
            ['1001'],
            CampaignPerformanceStat::query()->pluck('external_campaign_id')->all(),
        );
    }

    public function test_it_dumps_only_allowlisted_external_ids(): void
    {
        config(['campaign-performance.external_ids' => ['1002']]);

        $this->liveMetaCampaign('1001');
        $this->liveMetaCampaign('1002');

        Http::fake([
            '*/1002/insights*' => Http::response(['data' => [
                ['date_start' => '2026-09-22', 'spend' => '3.00', 'impressions' => '9', 'clicks' => '1'],
            ]]),
            '*/1002*' => Http::response(['id' => '1002', 'status' => 'ACTIVE', 'effective_status' => 'ACTIVE']),
            '*/1001/insights*' => Http::response(['data' => [
                ['date_start' => '2026-09-22', 'spend' => '9.00', 'impressions' => '9', 'clicks' => '1'],
            ]]),
            '*/1001*' => Http::response(['id' => '1001', 'status' => 'ACTIVE', 'effective_status' => 'ACTIVE']),
        ]);

        $this->artisan('campaigns:sync-performance', ['--dump' => true])->assertSuccessful();

        $this->assertEqualsCanonicalizing(
            ['1002'],
            CampaignPerformanceStat::query()->pluck('external_campaign_id')->all(),
        );
    }

    public function test_a_graph_failure_records_last_error_and_does_not_fail_the_command(): void
    {
        $campaign = $this->liveMetaCampaign('1201', ['status' => 'published']);

        Http::fake([
            '*/1201/insights*' => Http::response([
                'error' => ['message' => 'temporarily down', 'type' => 'OAuthException', 'code' => 1],
            ], 500),
            '*/1201*' => Http::response(['id' => '1201', 'status' => 'ACTIVE']),
        ]);

        $this->artisan('campaigns:sync-performance', ['--dump' => true])->assertSuccessful();

        $this->assertSame('published', $campaign->fresh()->status);
        $this->assertSame('1201', $campaign->fresh()->external_campaign_id);
        $errorRow = CampaignPerformanceStat::query()->where('external_campaign_id', '1201')->first();
        $this->assertNotNull($errorRow);
        $this->assertSame(0.0, (float) $errorRow->spend);
        $this->assertNotNull($errorRow->last_error);
    }

    public function test_a_second_sync_updates_the_same_stat_row(): void
    {
        $this->liveMetaCampaign('1201');

        Http::fake([
            '*/1201/insights*' => Http::sequence()
                ->push(['data' => [
                    ['date_start' => '2026-09-22', 'spend' => '1.00', 'impressions' => '10', 'clicks' => '1'],
                ]])
                ->push(['data' => [
                    ['date_start' => '2026-09-22', 'spend' => '4.50', 'impressions' => '20', 'clicks' => '2'],
                ]]),
            '*/1201*' => Http::response([
                'id' => '1201',
                'name' => 'Live',
                'status' => 'PAUSED',
                'effective_status' => 'PAUSED',
            ]),
        ]);

        $this->artisan('campaigns:sync-performance', ['--dump' => true])->assertSuccessful();
        $id = CampaignPerformanceStat::query()->value('id');

        $this->artisan('campaigns:sync-performance', ['--dump' => true])->assertSuccessful();

        $this->assertSame(1, CampaignPerformanceStat::query()->count());
        $this->assertSame($id, CampaignPerformanceStat::query()->value('id'));
        $this->assertSame(4.5, (float) CampaignPerformanceStat::query()->value('spend'));
    }

    public function test_a_dump_only_run_does_not_insert_suggestions(): void
    {
        $this->liveMetaCampaign('1201');
        $this->fakeMetaInsights('1201', [
            ['date_start' => '2026-09-22', 'spend' => '1.00', 'impressions' => '10', 'clicks' => '1'],
        ]);

        $this->artisan('campaigns:sync-performance', ['--dump' => true])->assertSuccessful();

        $this->assertTrue(Schema::hasTable('campaign_performance_suggestions'));
        $this->assertSame(0, CampaignPerformanceSuggestion::query()->count());
    }

    public function test_it_dumps_from_campaign_start_not_only_the_last_7_days(): void
    {
        $this->liveMetaCampaign('1201');

        Http::fake([
            '*/1201/insights*' => Http::response(['data' => [
                ['date_start' => '2026-09-01', 'spend' => '5.00', 'impressions' => '100', 'clicks' => '4'],
                ['date_start' => '2026-09-22', 'spend' => '8.00', 'impressions' => '200', 'clicks' => '6'],
            ]]),
            '*/1201*' => Http::response([
                'id' => '1201',
                'name' => 'Live',
                'status' => 'ACTIVE',
                'effective_status' => 'ACTIVE',
                'start_time' => '2026-09-01T08:00:00+0000',
            ]),
        ]);

        $this->artisan('campaigns:sync-performance', ['--dump' => true])->assertSuccessful();

        $this->assertEqualsCanonicalizing(
            ['2026-09-01', '2026-09-22'],
            CampaignPerformanceStat::query()->get()->map(fn ($row) => $row->stat_date->toDateString())->all(),
        );
        $this->assertTrue($this->insightsRequestedSince('1201', '2026-09-01'));
    }

    public function test_a_later_dump_only_refreshes_the_recent_window(): void
    {
        $campaign = $this->liveMetaCampaign('1201');

        CampaignPerformanceStat::create([
            'user_id' => $campaign->user_id,
            'platform' => 'meta',
            'external_campaign_id' => '1201',
            'local_campaign_id' => $campaign->id,
            'stat_date' => '2026-09-01',
            'spend' => 5.00,
            'impressions' => 100,
            'clicks' => 4,
            'platform_status' => 'ACTIVE',
            'fetched_at' => now()->subDay(),
        ]);

        Http::fake([
            '*/1201/insights*' => Http::response(['data' => [
                ['date_start' => '2026-09-22', 'spend' => '8.00', 'impressions' => '200', 'clicks' => '6'],
            ]]),
            '*/1201*' => Http::response([
                'id' => '1201',
                'status' => 'ACTIVE',
                'effective_status' => 'ACTIVE',
                'start_time' => '2026-09-01T08:00:00+0000',
            ]),
        ]);

        $this->artisan('campaigns:sync-performance', ['--dump' => true])->assertSuccessful();

        $this->assertTrue($this->insightsRequestedSince('1201', '2026-09-21'));
        $this->assertFalse($this->insightsRequestedSince('1201', '2026-09-01'));
        $this->assertTrue($this->insightsRequestedIncrement('1201', '1'));
        $this->assertSame(2, CampaignPerformanceStat::query()->count());
    }

    public function test_hourly_dump_stores_the_last_three_hours_and_skips_older_hours(): void
    {
        $this->liveMetaCampaign('1201');

        $this->fakeMetaInsights('1201', [
            $this->metaHour('2026-09-22', 7, '9.00', '90', '9'),
            $this->metaHour('2026-09-22', 8, '1.00', '10', '1'),
            $this->metaHour('2026-09-22', 9, '2.00', '20', '2'),
            $this->metaHour('2026-09-22', 10, '3.00', '30', '3'),
            $this->metaHour('2026-09-22', 11, '4.00', '40', '4'),
            $this->metaHour('2026-09-22', 12, '5.00', '50', '5'),
        ]);

        CampaignPerformanceHourStat::create([
            'user_id' => Campaign::query()->value('user_id'),
            'platform' => 'meta',
            'external_campaign_id' => '1201',
            'local_campaign_id' => Campaign::query()->value('id'),
            'stat_hour' => '2026-09-01 10:00:00',
            'spend' => 1,
            'impressions' => 1,
            'clicks' => 1,
            'platform_status' => 'ACTIVE',
            'fetched_at' => now()->subDay(),
        ]);

        $this->artisan('campaigns:sync-performance', ['--hourly' => true, '--dump' => true])->assertSuccessful();

        $this->assertTrue($this->insightsRequestedHourlyBreakdown('1201'));
        $this->assertFalse($this->insightsRequestedIncrement('1201', 'hourly'));
        $this->assertSame(0, CampaignPerformanceStat::query()->count());
        $this->assertSame(0, CampaignPerformanceSuggestion::query()->count());
        $this->assertEqualsCanonicalizing(
            [
                '2026-09-22 10:00:00',
                '2026-09-22 11:00:00',
                '2026-09-22 12:00:00',
            ],
            CampaignPerformanceHourStat::query()
                ->whereDate('stat_hour', '2026-09-22')
                ->get()
                ->map(fn ($row) => $row->stat_hour->toDateTimeString())
                ->all(),
        );
        $this->assertSame(5.0, (float) CampaignPerformanceHourStat::query()
            ->where('stat_hour', '2026-09-22 12:00:00')
            ->value('spend'));
    }

    public function test_hourly_dump_keeps_advertiser_clock_hours_instead_of_app_timezone(): void
    {
        $this->travelTo('2026-09-22 06:00:00');
        $this->liveMetaCampaign('1201');

        $this->fakeMetaInsights('1201', [
            $this->metaHour('2026-09-22', 4, '0.02', '2', '0'),
            $this->metaHour('2026-09-22', 5, '0.15', '8', '0'),
            $this->metaHour('2026-09-22', 6, '0.01', '1', '0'),
        ]);

        CampaignPerformanceHourStat::create([
            'user_id' => Campaign::query()->value('user_id'),
            'platform' => 'meta',
            'external_campaign_id' => '1201',
            'local_campaign_id' => Campaign::query()->value('id'),
            'stat_hour' => '2026-09-01 10:00:00',
            'spend' => 1,
            'impressions' => 1,
            'clicks' => 1,
            'platform_status' => 'ACTIVE',
            'fetched_at' => now()->subDay(),
        ]);

        $this->artisan('campaigns:sync-performance', ['--hourly' => true, '--dump' => true])->assertSuccessful();

        $this->assertEqualsCanonicalizing(
            [
                '2026-09-22 04:00:00',
                '2026-09-22 05:00:00',
                '2026-09-22 06:00:00',
            ],
            CampaignPerformanceHourStat::query()
                ->whereDate('stat_hour', '2026-09-22')
                ->get()
                ->map(fn ($row) => $row->stat_hour->toDateTimeString())
                ->all(),
        );
        $this->assertSame(0.02, (float) CampaignPerformanceHourStat::query()
            ->where('stat_hour', '2026-09-22 04:00:00')
            ->value('spend'));
    }

    public function test_hourly_dump_writes_a_row_for_each_hour_in_the_window(): void
    {
        $this->liveMetaCampaign('1201');

        $this->fakeMetaInsights('1201', [
            $this->metaHour('2026-09-22', 12, '5.00', '50', '5'),
        ]);

        CampaignPerformanceHourStat::create([
            'user_id' => Campaign::query()->value('user_id'),
            'platform' => 'meta',
            'external_campaign_id' => '1201',
            'local_campaign_id' => Campaign::query()->value('id'),
            'stat_hour' => '2026-09-01 10:00:00',
            'spend' => 1,
            'impressions' => 1,
            'clicks' => 1,
            'platform_status' => 'ACTIVE',
            'fetched_at' => now()->subDay(),
        ]);

        $this->artisan('campaigns:sync-performance', ['--hourly' => true, '--dump' => true])->assertSuccessful();

        $this->assertSame(3, CampaignPerformanceHourStat::query()->whereDate('stat_hour', '2026-09-22')->count());
        $this->assertEqualsCanonicalizing(
            [
                '2026-09-22 10:00:00',
                '2026-09-22 11:00:00',
                '2026-09-22 12:00:00',
            ],
            CampaignPerformanceHourStat::query()
                ->whereDate('stat_hour', '2026-09-22')
                ->get()
                ->map(fn ($row) => $row->stat_hour->toDateTimeString())
                ->all(),
        );
        $this->assertSame(0.0, (float) CampaignPerformanceHourStat::query()
            ->where('stat_hour', '2026-09-22 10:00:00')
            ->value('spend'));
        $this->assertSame(5.0, (float) CampaignPerformanceHourStat::query()
            ->where('stat_hour', '2026-09-22 12:00:00')
            ->value('spend'));
        $this->assertTrue(CampaignPerformanceHourStat::query()->whereNotNull('last_error')->doesntExist());
    }

    public function test_a_successful_hourly_dump_clears_previous_hour_errors(): void
    {
        $campaign = $this->liveMetaCampaign('1201');

        CampaignPerformanceHourStat::create([
            'user_id' => $campaign->user_id,
            'platform' => 'meta',
            'external_campaign_id' => '1201',
            'local_campaign_id' => $campaign->id,
            'stat_hour' => '2026-09-01 10:00:00',
            'spend' => 1,
            'impressions' => 1,
            'clicks' => 1,
            'platform_status' => 'ACTIVE',
            'fetched_at' => now()->subDay(),
        ]);

        CampaignPerformanceHourStat::create([
            'user_id' => $campaign->user_id,
            'platform' => 'meta',
            'external_campaign_id' => '1201',
            'local_campaign_id' => $campaign->id,
            'stat_hour' => '2026-09-22 13:00:00',
            'spend' => 0,
            'impressions' => 0,
            'clicks' => 0,
            'last_error' => '(#100) name_placeholder must be one of the following values: monthly, all_days',
            'fetched_at' => now()->subHour(),
        ]);

        $this->fakeMetaInsights('1201', [
            $this->metaHour('2026-09-22', 6, '0.01', '1', '0'),
        ]);

        $this->artisan('campaigns:sync-performance', ['--hourly' => true, '--dump' => true])->assertSuccessful();

        $this->assertTrue(
            CampaignPerformanceHourStat::query()->whereNotNull('last_error')->doesntExist(),
        );
        $this->assertSame(3, CampaignPerformanceHourStat::query()->whereDate('stat_hour', '2026-09-22')->count());
    }

    public function test_hourly_dump_includes_hours_from_yesterday_near_midnight(): void
    {
        $this->travelTo('2026-09-22 02:00:00');
        $this->liveMetaCampaign('1201');

        $this->fakeMetaInsights('1201', [
            $this->metaHour('2026-09-21', 21, '9.00', '90', '9'),
            $this->metaHour('2026-09-21', 22, '1.00', '10', '1'),
            $this->metaHour('2026-09-21', 23, '2.00', '20', '2'),
            $this->metaHour('2026-09-22', 0, '3.00', '30', '3'),
            $this->metaHour('2026-09-22', 1, '4.00', '40', '4'),
            $this->metaHour('2026-09-22', 2, '5.00', '50', '5'),
        ]);

        CampaignPerformanceHourStat::create([
            'user_id' => Campaign::query()->value('user_id'),
            'platform' => 'meta',
            'external_campaign_id' => '1201',
            'local_campaign_id' => Campaign::query()->value('id'),
            'stat_hour' => '2026-09-01 10:00:00',
            'spend' => 1,
            'impressions' => 1,
            'clicks' => 1,
            'platform_status' => 'ACTIVE',
            'fetched_at' => now()->subDay(),
        ]);

        $this->artisan('campaigns:sync-performance', ['--hourly' => true, '--dump' => true])->assertSuccessful();

        $this->assertTrue($this->insightsRequestedSince('1201', '2026-09-22'));
        $this->assertEqualsCanonicalizing(
            [
                '2026-09-22 00:00:00',
                '2026-09-22 01:00:00',
                '2026-09-22 02:00:00',
            ],
            CampaignPerformanceHourStat::query()
                ->whereDate('stat_hour', '2026-09-22')
                ->get()
                ->map(fn ($row) => $row->stat_hour->toDateTimeString())
                ->all(),
        );
    }

    public function test_hourly_google_dump_converts_cost_micros_and_keeps_the_last_three_hours(): void
    {
        config(['campaign-performance.platforms' => ['google']]);

        $campaign = $this->liveGoogleCampaign('222');
        $this->fakeGoogleHourlyMetrics('222', [
            ['date' => '2026-09-22', 'hour' => 7, 'cost' => '9000000'],
            ['date' => '2026-09-22', 'hour' => 8, 'cost' => '1000000'],
            ['date' => '2026-09-22', 'hour' => 9, 'cost' => '2000000'],
            ['date' => '2026-09-22', 'hour' => 10, 'cost' => '3000000'],
            ['date' => '2026-09-22', 'hour' => 11, 'cost' => '4000000'],
            ['date' => '2026-09-22', 'hour' => 12, 'cost' => '1500000'],
        ]);

        CampaignPerformanceHourStat::create([
            'user_id' => $campaign->user_id,
            'platform' => 'google',
            'external_campaign_id' => '222',
            'local_google_ads_campaign_id' => $campaign->id,
            'stat_hour' => '2026-09-01 10:00:00',
            'spend' => 1,
            'impressions' => 1,
            'clicks' => 1,
            'platform_status' => 'ENABLED',
            'fetched_at' => now()->subDay(),
        ]);

        $this->artisan('campaigns:sync-performance', ['--hourly' => true, '--dump' => true])->assertSuccessful();

        $this->assertTrue($this->googleRequestedHourlySegment());
        $this->assertSame($campaign->id, CampaignPerformanceHourStat::query()->value('local_google_ads_campaign_id'));
        $this->assertSame(3, CampaignPerformanceHourStat::query()->whereDate('stat_hour', '2026-09-22')->count());
        $this->assertSame(1.5, (float) CampaignPerformanceHourStat::query()
            ->where('stat_hour', '2026-09-22 12:00:00')
            ->value('spend'));
        $this->assertSame(0, CampaignPerformanceStat::query()->count());
    }

    public function test_a_second_hourly_dump_updates_the_same_hour_row(): void
    {
        $this->liveMetaCampaign('1201');

        CampaignPerformanceHourStat::create([
            'user_id' => Campaign::query()->value('user_id'),
            'platform' => 'meta',
            'external_campaign_id' => '1201',
            'local_campaign_id' => Campaign::query()->value('id'),
            'stat_hour' => '2026-09-01 10:00:00',
            'spend' => 1,
            'impressions' => 1,
            'clicks' => 1,
            'platform_status' => 'ACTIVE',
            'fetched_at' => now()->subDay(),
        ]);

        Http::fake([
            '*/1201/insights*' => Http::sequence()
                ->push(['data' => [
                    $this->metaHour('2026-09-22', 12, '1.00', '10', '1'),
                ]])
                ->push(['data' => [
                    $this->metaHour('2026-09-22', 12, '4.50', '20', '2'),
                ]]),
            '*/1201*' => Http::response([
                'id' => '1201',
                'name' => 'Live',
                'status' => 'PAUSED',
                'effective_status' => 'PAUSED',
            ]),
        ]);

        $this->artisan('campaigns:sync-performance', ['--hourly' => true, '--dump' => true])->assertSuccessful();
        $id = CampaignPerformanceHourStat::query()->where('stat_hour', '2026-09-22 12:00:00')->value('id');

        $this->app->forgetScopedInstances();

        $this->artisan('campaigns:sync-performance', ['--hourly' => true, '--dump' => true])->assertSuccessful();

        $this->assertSame(3, CampaignPerformanceHourStat::query()->whereDate('stat_hour', '2026-09-22')->count());
        $this->assertSame($id, CampaignPerformanceHourStat::query()->where('stat_hour', '2026-09-22 12:00:00')->value('id'));
        $this->assertSame(4.5, (float) CampaignPerformanceHourStat::query()->where('stat_hour', '2026-09-22 12:00:00')->value('spend'));
    }

    public function test_it_backfills_hourly_stats_from_campaign_start(): void
    {
        $this->liveMetaCampaign('1201');

        Http::fake([
            '*/1201/insights*' => Http::response(['data' => [
                $this->metaHour('2026-09-01', 10, '1.00', '10', '1'),
                $this->metaHour('2026-09-22', 12, '5.00', '50', '5'),
            ]]),
            '*/1201*' => Http::response([
                'id' => '1201',
                'name' => 'Live',
                'status' => 'ACTIVE',
                'effective_status' => 'ACTIVE',
                'start_time' => '2026-09-01T08:00:00+0000',
            ]),
        ]);

        $this->artisan('campaigns:sync-performance', ['--hourly' => true, '--dump' => true])->assertSuccessful();

        $this->assertTrue($this->insightsRequestedSince('1201', '2026-09-01'));
        $this->assertNotNull(
            CampaignPerformanceHourStat::query()->where('stat_hour', '2026-09-01 10:00:00')->first(),
        );
        $this->assertSame(5.0, (float) CampaignPerformanceHourStat::query()
            ->where('stat_hour', '2026-09-22 12:00:00')
            ->value('spend'));
    }

    public function test_a_later_hourly_dump_only_refreshes_the_recent_three_hours(): void
    {
        $campaign = $this->liveMetaCampaign('1201');

        CampaignPerformanceHourStat::create([
            'user_id' => $campaign->user_id,
            'platform' => 'meta',
            'external_campaign_id' => '1201',
            'local_campaign_id' => $campaign->id,
            'stat_hour' => '2026-09-01 10:00:00',
            'spend' => 1.00,
            'impressions' => 10,
            'clicks' => 1,
            'platform_status' => 'ACTIVE',
            'fetched_at' => now()->subDay(),
        ]);

        Http::fake([
            '*/1201/insights*' => Http::response(['data' => [
                $this->metaHour('2026-09-22', 12, '5.00', '50', '5'),
            ]]),
            '*/1201*' => Http::response([
                'id' => '1201',
                'status' => 'ACTIVE',
                'effective_status' => 'ACTIVE',
                'start_time' => '2026-09-01T08:00:00+0000',
            ]),
        ]);

        $this->artisan('campaigns:sync-performance', ['--hourly' => true, '--dump' => true])->assertSuccessful();

        $this->assertTrue($this->insightsRequestedSince('1201', '2026-09-22'));
        $this->assertFalse($this->insightsRequestedSince('1201', '2026-09-01'));
        $this->assertSame(1.0, (float) CampaignPerformanceHourStat::query()
            ->where('stat_hour', '2026-09-01 10:00:00')
            ->value('spend'));
        $this->assertEqualsCanonicalizing(
            [
                '2026-09-22 10:00:00',
                '2026-09-22 11:00:00',
                '2026-09-22 12:00:00',
            ],
            CampaignPerformanceHourStat::query()
                ->whereDate('stat_hour', '2026-09-22')
                ->get()
                ->map(fn ($row) => $row->stat_hour->toDateTimeString())
                ->all(),
        );
    }

    public function test_an_hourly_graph_failure_records_last_error_on_the_hour_row(): void
    {
        $this->liveMetaCampaign('1201');

        Http::fake([
            '*/1201/insights*' => Http::response([
                'error' => ['message' => 'temporarily down', 'type' => 'OAuthException', 'code' => 1],
            ], 500),
            '*/1201*' => Http::response(['id' => '1201', 'status' => 'ACTIVE']),
        ]);

        $this->artisan('campaigns:sync-performance', ['--hourly' => true, '--dump' => true])
            ->expectsOutputToContain('temporarily down')
            ->assertSuccessful();

        $errorRow = CampaignPerformanceHourStat::query()->first();
        $this->assertNotNull($errorRow);
        $this->assertSame(0.0, (float) $errorRow->spend);
        $this->assertNotNull($errorRow->last_error);
        $this->assertSame('2026-09-22 12:00:00', $errorRow->stat_hour->toDateTimeString());
        $this->assertSame(0, CampaignPerformanceStat::query()->count());
    }

    private function insightsRequestedSince(string $campaignId, string $since): bool
    {
        return collect(Http::recorded())
            ->map(fn (array $pair) => $pair[0])
            ->contains(function ($request) use ($campaignId, $since): bool {
                if (! str_contains($request->url(), $campaignId.'/insights')) {
                    return false;
                }

                $range = $request['time_range'] ?? '';

                if (is_string($range) && str_contains($range, $since)) {
                    return true;
                }

                $query = [];
                parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query);

                return str_contains((string) ($query['time_range'] ?? ''), $since);
            });
    }

    private function insightsRequestedIncrement(string $campaignId, string $increment): bool
    {
        return collect(Http::recorded())
            ->map(fn (array $pair) => $pair[0])
            ->contains(function ($request) use ($campaignId, $increment): bool {
                if (! str_contains($request->url(), $campaignId.'/insights')) {
                    return false;
                }

                $query = [];
                parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query);

                return (string) ($query['time_increment'] ?? $request['time_increment'] ?? '') === $increment;
            });
    }

    private function insightsRequestedHourlyBreakdown(string $campaignId): bool
    {
        return collect(Http::recorded())
            ->map(fn (array $pair) => $pair[0])
            ->contains(function ($request) use ($campaignId): bool {
                if (! str_contains($request->url(), $campaignId.'/insights')) {
                    return false;
                }

                $query = [];
                parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query);
                $breakdown = (string) ($query['breakdowns'] ?? $request['breakdowns'] ?? '');
                $increment = (string) ($query['time_increment'] ?? $request['time_increment'] ?? '');

                return str_contains($breakdown, 'hourly_stats_aggregated_by_advertiser_time_zone')
                    && $increment === '1';
            });
    }

    private function googleRequestedHourlySegment(): bool
    {
        return collect(Http::recorded())
            ->map(fn (array $pair) => $pair[0])
            ->contains(function ($request): bool {
                if (! str_contains($request->url(), 'googleAds:searchStream')) {
                    return false;
                }

                $query = (string) data_get($request->data(), 'query', $request['query'] ?? '');

                return str_contains($query, 'segments.hour');
            });
    }

    /**
     * @return array<string, mixed>
     */
    private function metaHour(string $date, int $hour, string $spend, string $impressions, string $clicks): array
    {
        return [
            'date_start' => $date,
            'date_stop' => $date,
            'hourly_stats_aggregated_by_advertiser_time_zone' => sprintf('%02d:00:00 - %02d:59:59', $hour, $hour),
            'spend' => $spend,
            'impressions' => $impressions,
            'clicks' => $clicks,
        ];
    }

    /**
     * @param  array<string, mixed>  $overrides
     */
    private function liveMetaCampaign(string $externalId, array $overrides = []): Campaign
    {
        return Campaign::create(array_merge([
            'user_id' => User::factory()->create()->id,
            'name' => 'Live Meta '.$externalId,
            'platform' => 'meta',
            'status' => 'published',
            'budget' => 20,
            'ad_account_id' => 'act_111',
            'external_campaign_id' => $externalId,
        ], $overrides));
    }

    private function liveGoogleCampaign(string $googleCampaignId): GoogleAdsCampaign
    {
        $user = User::factory()->create();
        $connection = GoogleAdsConnection::factory()->create(['user_id' => $user->id]);

        return GoogleAdsCampaign::create([
            'user_id' => $user->id,
            'google_ads_connection_id' => $connection->id,
            'google_campaign_id' => $googleCampaignId,
            'name' => 'Live Google',
            'status' => 'paused',
            'daily_budget' => 20,
        ]);
    }

    /**
     * @param  list<array<string, mixed>>  $days
     * @param  list<array<string, mixed>>  $adSets
     */
    private function fakeMetaInsights(string $campaignId, array $days, array $adSets = []): void
    {
        Http::fake(function (Request $request) use ($campaignId, $days, $adSets) {
            $url = $request->url();

            if (str_contains($url, '/insights')) {
                return Http::response(['data' => $days]);
            }

            if (str_contains($url, '/adsets')) {
                return Http::response(['data' => $adSets]);
            }

            if (str_contains($url, $campaignId)) {
                return Http::response([
                    'id' => $campaignId,
                    'name' => 'Live',
                    'status' => 'PAUSED',
                    'effective_status' => 'PAUSED',
                ]);
            }

            return Http::response([], 404);
        });
    }

    private function fakeGoogleMetrics(string $campaignId, string $costMicros): void
    {
        Http::fake([
            'oauth2.googleapis.com/token' => Http::response(['access_token' => 'access-token']),
            'googleads.googleapis.com/*' => Http::response([
                [
                    'results' => [[
                        'campaign' => [
                            'id' => $campaignId,
                            'name' => 'Live Google',
                            'status' => 'PAUSED',
                        ],
                        'segments' => ['date' => '2026-09-22'],
                        'metrics' => [
                            'impressions' => '100',
                            'clicks' => '4',
                            'costMicros' => $costMicros,
                            'ctr' => 0.04,
                            'averageCpc' => '375000',
                        ],
                    ]],
                ],
            ]),
        ]);
    }

    /**
     * @param  list<array{date: string, hour: int, cost: string}>  $hours
     */
    private function fakeGoogleHourlyMetrics(string $campaignId, array $hours): void
    {
        Http::fake([
            'oauth2.googleapis.com/token' => Http::response(['access_token' => 'access-token']),
            'googleads.googleapis.com/*' => Http::response([
                [
                    'results' => array_map(fn (array $hour): array => [
                        'campaign' => [
                            'id' => $campaignId,
                            'name' => 'Live Google',
                            'status' => 'PAUSED',
                        ],
                        'segments' => [
                            'date' => $hour['date'],
                            'hour' => $hour['hour'],
                        ],
                        'metrics' => [
                            'impressions' => '100',
                            'clicks' => '4',
                            'costMicros' => $hour['cost'],
                            'ctr' => 0.04,
                            'averageCpc' => '375000',
                        ],
                    ], $hours),
                ],
            ]),
        ]);
    }
}
