<?php

namespace Tests\Feature;

use App\Agent\Choices;
use App\Agent\ToolRegistry;
use App\Agent\Workspace;
use App\Campaigns\LaunchFlow;
use App\Campaigns\Publisher;
use App\Campaigns\PublishGate;
use App\Models\Campaign;
use App\Models\Creative;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Laravel\Ai\Tools\Request;
use Tests\SeedsVerifiedCountries;
use Tests\TestCase;

/**
 * The whole flow, start to finish, in the order the product leads.
 *
 * Every other test covers one step. This one walks all of them, taking the next
 * step from LaunchFlow rather than from a list written here, so a step added to
 * the flow and forgotten everywhere else shows up as this test never finishing.
 *
 * It also covers the arrangement nothing else does end to end: a lifetime
 * budget held on the campaign, which moves the money and the bid strategy up a
 * level, makes an end date compulsory, and leaves the ad set with neither.
 */
class LaunchFlowEndToEndTest extends TestCase
{
    use RefreshDatabase;
    use SeedsVerifiedCountries;

    protected Creative $creative;

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

        $this->seedVerifiedCountries();

        config([
            'platforms.guardrails.allowed_ad_accounts' => ['act_111'],
            'platforms.guardrails.max_daily_budget' => 50,
            'platforms.meta.mcp.enabled' => false,
            'platforms.meta.token' => 'test-token',
        ]);

        Storage::fake('local');
        Storage::fake('public');
        Storage::disk('public')->put('creatives/images/one.png', 'bytes');

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

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

        // Something the creative generator made, which is where creatives come
        // from in the real product rather than from an upload in the chat.
        $this->creative = Creative::create([
            'user_id' => $user->id,
            'type' => 'image',
            'status' => 'generating',
            'source' => 'prompt',
            'prompt' => 'a clean product shot',
        ]);

        $this->creative->update(['status' => 'completed', 'asset_path' => 'creatives/images/one.png']);
        $this->creative->refresh();
    }

    /**
     * Not every tool answers in JSON: import_creatives replies in a sentence,
     * because what it returns is read by the model rather than by us.
     *
     * @return array<string, mixed>
     */
    private function runTool(string $name, array $arguments = []): array
    {
        $tool = collect(app(ToolRegistry::class)->resolve())->first(fn ($t) => $t->name() === $name);

        $this->assertNotNull($tool, "Tool {$name} is not registered.");

        $reply = (string) $tool->handle(new Request($arguments));

        return json_decode($reply, true) ?? ['reply' => $reply];
    }

    private function fakeMeta(): void
    {
        Http::fake([
            '*/me/accounts*' => Http::response(['data' => [['id' => 'page-1', 'name' => 'Test Page']]]),
            '*/adspixels*' => Http::response(['data' => [['id' => 'pixel-1', 'name' => 'Test Pixel']]]),
            // The events the pixel has fired, which is what the conversion
            // event step offers. Bucketed as Meta buckets them, hourly, so the
            // summing across buckets is exercised rather than assumed.
            '*/stats*' => Http::response(['data' => [
                ['aggregation' => 'event', 'data' => [['value' => 'Purchase', 'count' => 4], ['value' => 'PageView', 'count' => 9]]],
                ['aggregation' => 'event', 'data' => [['value' => 'Purchase', 'count' => 2]]],
            ]]),
            '*/page-1*' => Http::response([
                'connected_instagram_account' => ['id' => 'ig-1', 'username' => 'wintersale'],
            ]),
            '*/instagram_accounts*' => Http::response(['data' => [['id' => 'ig-1', 'username' => 'wintersale']]]),
            '*/act_111/campaigns' => Http::response(['id' => 'camp-1']),
            '*/act_111/adsets' => Http::response(['id' => 'adset-1']),
            '*/act_111/adcreatives' => Http::response(['id' => 'creative-1']),
            '*/act_111/ads' => Http::response(['id' => 'ad-1']),
            '*/act_111/adimages' => Http::response(['images' => ['x' => ['hash' => 'hash-1']]]),
            '*/act_111*' => Http::response([
                'id' => 'act_111', 'name' => 'Test Account',
                'account_status' => 1, 'currency' => 'USD',
            ]),
            '*' => Http::response(['data' => []]),
        ]);
    }

    /** What the user is answering at each step, keyed by the step LaunchFlow names. */
    private function answerFor(string $key): array
    {
        return match ($key) {
            'ad_account' => ['campaign__verify_ad_account', ['ad_account_id' => '111']],
            'objective' => ['campaign__choose_objective', ['objective' => 'OUTCOME_SALES']],
            'special_ad_categories' => ['campaign__declare_ad_category', ['categories' => ['NONE']]],
            'landing_url' => ['campaign__set_campaign_basics', ['landing_url' => 'https://example.com/offer?ref=partner']],
            'page' => ['campaign__choose_page', ['page_id' => 'page-1']],
            'pixel' => ['campaign__choose_pixel', ['pixel_id' => 'pixel-1']],
            'conversion_event' => ['campaign__choose_conversion_event', ['conversion_event' => 'Purchase']],
            'budget' => ['campaign__set_campaign_budget', ['level' => 'campaign', 'mode' => 'lifetime', 'amount' => 600]],
            'bidding' => ['campaign__set_bidding', ['strategy' => 'COST_CAP', 'target_cost' => 4]],
            'schedule' => ['campaign__set_schedule', ['ends_at' => now()->addDays(30)->toIso8601String()]],
            'targeting' => ['campaign__set_targeting', [
                'countries' => ['US'],
                'placements' => ['facebook', 'instagram'],
                'devices' => ['mobile'],
            ]],
            'instagram' => ['campaign__choose_instagram_account', ['instagram_account_id' => 'ig-1']],
            'utm' => ['campaign__set_tracking', ['utm' => 'utm_source=facebook&utm_campaign={{campaign.name}}']],
            // What clicking a creative thumbnail does: brings it into the
            // conversation, ready for the copy step to pair it with an ad.
            'creatives' => ['campaign__import_creatives', ['creative_ids' => [$this->creative->id]]],
            'copy' => ['campaign__write_ad_copy', ['ads' => [[
                'headline' => 'Winter Sale',
                'primary_text' => 'Half price until Sunday.',
                'cta' => 'SHOP_NOW',
            ]]]],
            default => $this->fail("No answer written for the [{$key}] step. Add one when adding a step."),
        };
    }

    public function test_a_campaign_is_built_one_step_at_a_time_and_reaches_meta_intact(): void
    {
        $this->fakeMeta();

        $created = $this->runTool('campaign__start_campaign', ['name' => 'Winter Sale', 'platform' => 'meta']);
        $campaign = Campaign::find($created['campaign_id']);

        app(Workspace::class)->focusOn($campaign);

        $flow = app(LaunchFlow::class);
        $walked = [];

        // Driven by the flow, not by a list here: a step nobody can answer
        // fails on the next line rather than being quietly skipped.
        while ($next = $flow->next($campaign->fresh())) {
            $this->assertNotContains(
                $next['key'],
                $walked,
                "The flow asked for [{$next['key']}] twice, so answering it did not settle it.",
            );

            $walked[] = $next['key'];

            [$tool, $arguments] = $this->answerFor($next['key']);
            $result = $this->runTool($tool, $arguments);

            $this->assertNotFalse(
                $result['ok'] ?? true,
                "[{$next['key']}] was refused: ".json_encode($result),
            );

            $this->assertLessThan(25, count($walked), 'The flow is not converging.');
        }

        // Shared questions, then the platform's own, then the creative that
        // every platform shares. A second platform repeats only the middle.
        //
        // The landing page leads because it is the one thing the buyer arrives
        // knowing, and reading it is what lets the rest be recommendations. It
        // used to be fourth, behind the ad account, the objective and the
        // category: three pieces of administration before they had said what
        // they were advertising.
        $this->assertSame([
            // the brief
            'landing_url', 'budget', 'schedule',
            // Meta's own
            'ad_account', 'objective', 'special_ad_categories',
            'page', 'pixel', 'conversion_event', 'bidding', 'targeting', 'instagram',
            // No utm: click tracking is set from the platform's own macros
            // when the campaign is created, not asked for.
            // shared again, and last, because copy pairs ads with creatives
            'creatives', 'copy',
        ], $walked);

        // Tracking is no longer one of the steps: it is set from the platform's
        // own macros when the campaign is created. Changing it is still a thing
        // a buyer can ask for, and this walk covers a changed one reaching the
        // creative, so it is set here rather than walked.
        $this->runTool('campaign__set_tracking', [
            'utm' => 'utm_source=facebook&utm_campaign={{campaign.name}}',
        ]);

        // The upload to Meta, which is not a question anyone is asked.
        $this->runTool('campaign__review_assets', []);

        // Nothing is guessed and nothing is outstanding.
        $this->assertSame([], app(PublishGate::class)->check($campaign->fresh()));

        $publisher = app(Publisher::class);
        $decision = $publisher->approve($publisher->propose($campaign->fresh(), 'test'), 'test');

        $this->assertTrue($decision->wasApplied(), 'Publishing failed: '.$decision->reason);

        $this->assertCampaignCarriesTheBudget();
        $this->assertAdSetCarriesNeither();
        $this->assertCreativeIsTrackedAndOnInstagram();
    }

    /** Advantage campaign budget: the money and the strategy move up a level. */
    private function assertCampaignCarriesTheBudget(): void
    {
        Http::assertSent(function ($request): bool {
            if (! str_ends_with($request->url(), '/campaigns')) {
                return false;
            }

            $data = $request->data();

            return ($data['lifetime_budget'] ?? null) === 60000
                && ($data['bid_strategy'] ?? null) === 'COST_CAP'
                && ($data['is_adset_budget_sharing_enabled'] ?? null) === 'false'
                && filled($data['stop_time'] ?? null)
                && ($data['special_ad_categories'] ?? null) === '[]';
        });
    }

    /**
     * And the ad set must carry neither, or Meta rejects it. The cost cap
     * amount does stay here: it is a per-ad-set target even when Meta is
     * deciding the split.
     */
    private function assertAdSetCarriesNeither(): void
    {
        Http::assertSent(function ($request): bool {
            if (! str_contains($request->url(), '/adsets')) {
                return false;
            }

            $data = $request->data();
            $targeting = json_decode((string) ($data['targeting'] ?? '{}'), true);

            return ! isset($data['daily_budget'])
                && ! isset($data['lifetime_budget'])
                && ! isset($data['bid_strategy'])
                && ($data['bid_amount'] ?? null) === 400
                && ($targeting['device_platforms'] ?? null) === ['mobile']
                && ($targeting['publisher_platforms'] ?? null) === ['facebook', 'instagram']
                && filled($data['end_time'] ?? null);
        });
    }

    /**
     * The destination keeps the landing page's own query string, and the ad
     * runs as the Instagram account rather than as the Page.
     */
    private function assertCreativeIsTrackedAndOnInstagram(): void
    {
        Http::assertSent(function ($request): bool {
            if (! str_contains($request->url(), '/adcreatives')) {
                return false;
            }

            $data = $request->data();
            $story = json_decode((string) ($data['object_story_spec'] ?? '{}'), true);
            $link = $story['link_data']['link'] ?? '';

            return $link === 'https://example.com/offer?ref=partner&utm_source=facebook&utm_campaign={{campaign.name}}'
                && ($data['instagram_user_id'] ?? null) === 'ig-1';
        });
    }

    /**
     * The same walk with the ordinary arrangement, which is what most campaigns
     * are and what every campaign published so far was.
     */
    public function test_a_daily_ad_set_budget_needs_no_end_date_and_keeps_the_money_on_the_ad_set(): void
    {
        $this->fakeMeta();

        $created = $this->runTool('campaign__start_campaign', ['name' => 'Winter Sale', 'platform' => 'meta']);
        $campaign = Campaign::find($created['campaign_id']);
        app(Workspace::class)->focusOn($campaign);

        $this->runTool('campaign__verify_ad_account', ['ad_account_id' => '111']);
        $this->runTool('campaign__choose_objective', ['objective' => 'OUTCOME_TRAFFIC']);
        $this->runTool('campaign__set_campaign_budget', ['amount' => 20]);

        $flow = app(LaunchFlow::class);
        $keys = array_column($flow->progress($campaign->fresh()), 'key');

        // A traffic campaign needs no pixel and a daily budget needs no end date,
        // so neither is a step that exists.
        $this->assertNotContains('pixel', $keys);
        $this->assertNotContains('schedule', $keys);

        $this->runTool('campaign__set_bidding', ['strategy' => 'LOWEST_COST_WITHOUT_CAP']);
        $this->runTool('campaign__set_campaign_basics', ['landing_url' => 'https://example.com/offer']);
        $this->runTool('campaign__choose_page', ['page_id' => 'page-1']);
        $this->runTool('campaign__set_targeting', ['countries' => ['US'], 'placements' => ['facebook']]);
        $this->runTool('campaign__declare_ad_category', ['categories' => ['NONE']]);
        $this->runTool('campaign__set_tracking', ['utm' => '']);

        // Facebook only, so the Instagram account is not a step either.
        $this->assertNotContains('instagram', array_column($flow->progress($campaign->fresh()), 'key'));

        $this->assertSame('creatives', $flow->next($campaign->fresh())['key']);
    }

    /**
     * Every step that says it offers options must actually put some on screen.
     *
     * The controller calls these itself when a turn ends with an outstanding
     * step and nothing offered, so a step flagged as offering that quietly
     * offers nothing leaves the user looking at a question with no buttons.
     */
    public function test_every_step_flagged_as_offering_options_offers_some(): void
    {
        $this->fakeMeta();

        $created = $this->runTool('campaign__start_campaign', ['name' => 'Winter Sale', 'platform' => 'meta']);
        $campaign = Campaign::find($created['campaign_id']);
        app(Workspace::class)->focusOn($campaign);

        // Enough state for each tool to get as far as offering.
        $campaign->update([
            'ad_account_id' => 'act_111',
            'objective' => 'OUTCOME_SALES',
            'placements' => ['instagram'],
            'budget_mode' => 'lifetime',
            // The conversion event step reads its options off the pixel, so
            // there has to be one before it can offer anything.
            'pixel_id' => 'pixel-1',
        ]);

        $offering = collect(app(LaunchFlow::class)->progress($campaign->fresh()))
            ->pluck('key')
            ->all();

        $this->assertNotEmpty($offering);

        foreach ($offering as $key) {
            $tool = collect(app(LaunchFlow::class)->progress($campaign->fresh()))->firstWhere('key', $key);

            // progress() reports label and done; the tool and the flag come from
            // next(), so only the steps still outstanding can be probed here.
            if ($tool === null) {
                continue;
            }
        }

        // Probed directly: each offering tool, called bare, must leave something
        // for the user to click.
        foreach ([
            'campaign__choose_objective' => 'objective',
            'campaign__declare_ad_category' => 'special_ad_categories',
            'campaign__choose_page' => 'page_id',
            'campaign__choose_pixel' => 'pixel_id',
            'campaign__choose_conversion_event' => 'conversion_event',
            'campaign__set_campaign_budget' => 'budget_amount',
            'campaign__set_bidding' => 'bid_strategy',
            'campaign__set_schedule' => 'ends_at',
            'campaign__set_targeting' => 'countries',
            'campaign__choose_instagram_account' => 'instagram_account_id',
            'campaign__set_tracking' => 'utm',
        ] as $tool => $field) {
            app(Choices::class)->drain();

            $this->runTool($tool);

            $this->assertNotNull(
                collect(app(Choices::class)->all())->firstWhere('field', $field),
                "[{$tool}] is in the flow as offering options but put none on screen.",
            );
        }
    }
}
