<?php

namespace Tests\Feature;

use App\Agent\Choices;
use App\Agent\NamespacedTool;
use App\Agent\ToolRegistry;
use App\Agent\Workspace;
use App\Campaigns\Publisher;
use App\Models\Asset;
use App\Models\AssetPlatformRef;
use App\Models\Campaign;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Laravel\Ai\Contracts\Approvable;
use Laravel\Ai\Tools\Request;
use Tests\TestCase;

/**
 * A campaign is built paused and starts only when somebody asks.
 *
 * Publishing assembles a campaign on the platform and stops: Meta and Google
 * PAUSED at every level, LinkedIn DRAFT. Starting it is a separate act with its own
 * tool and its own approval, and until today there was no way to do it at all -
 * the product could stop money being spent and not start it, which is half a
 * feature and the wrong half to have.
 *
 * Two decisions worth stating because they shaped everything here. The approval is
 * separate from the publish approval: one covering both would make "created
 * paused" a description of a state nobody ever looks at rather than the point at
 * which somebody does. And activation sets the whole tree live, because a live
 * campaign over a paused ad set delivers nothing and reports it as a campaign with
 * no impressions - indistinguishable from one that is simply not winning auctions,
 * and the most expensive silence this product can produce.
 */
class NothingSpendsUntilSomebodySaysSoTest extends TestCase
{
    use RefreshDatabase;

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

        config([
            'platforms.meta.token' => 'test-token',
            'platforms.guardrails.allowed_ad_accounts' => ['meta:*'],
            'platforms.guardrails.max_daily_budget' => 1000,
        ]);
    }

    // ------------------------------------------------- the two states are distinct

    /** A published campaign is not a running one, and the model says which. */
    public function test_published_is_not_running(): void
    {
        $campaign = $this->publishedCampaign();

        $this->assertTrue($campaign->isPublished());
        $this->assertFalse($campaign->isRunning(), 'a freshly published campaign reads as running');
        $this->assertNull($campaign->activated_at);
    }

    /** A draft is neither. */
    public function test_a_draft_is_neither_published_nor_running(): void
    {
        $campaign = $this->campaign();

        $this->assertFalse($campaign->isPublished());
        $this->assertFalse($campaign->isRunning());
    }

    /** Once started, both. */
    public function test_a_started_campaign_is_published_and_running(): void
    {
        $campaign = $this->publishedCampaign();
        $campaign->update(['activated_at' => now()]);

        $this->assertTrue($campaign->fresh()->isPublished());
        $this->assertTrue($campaign->fresh()->isRunning());
    }

    // ------------------------------------------------------ starting it, for real

    /** Every level goes live, not just the campaign. */
    public function test_activation_starts_every_level(): void
    {
        $campaign = $this->publishedCampaign();

        Http::fake(['*' => Http::response(['success' => true])]);

        app(Publisher::class)->activate(
            app(Publisher::class)->proposeActivation($campaign, 'tester'),
            'tester',
        );

        $sent = collect(Http::recorded())->map(fn (array $pair): string => $pair[0]->url());

        foreach (['120252047635070626', 'adset-1', 'ad-1'] as $level) {
            $this->assertTrue(
                $sent->contains(fn (string $url): bool => str_contains($url, $level)),
                "{$level} was never set live",
            );
        }

        // Deepest first, so a failure part way leaves the campaign still paused.
        $this->assertLessThan(
            $sent->search(fn (string $url): bool => str_contains($url, '120252047635070626')),
            $sent->search(fn (string $url): bool => str_contains($url, 'ad-1')),
            'the campaign was started before the ads under it',
        );
    }

    /** And everything is set ACTIVE, which is what "live" means on Meta. */
    public function test_every_level_is_sent_active(): void
    {
        $campaign = $this->publishedCampaign();

        Http::fake(['*' => Http::response(['success' => true])]);

        app(Publisher::class)->activate(
            app(Publisher::class)->proposeActivation($campaign, 'tester'),
            'tester',
        );

        foreach (Http::recorded() as [$request]) {
            $this->assertSame('ACTIVE', $request->data()['status'] ?? null, $request->url());
        }
    }

    /** The local record is written, and only after the platform agreed. */
    public function test_the_campaign_is_recorded_as_running(): void
    {
        $campaign = $this->publishedCampaign();

        Http::fake(['*' => Http::response(['success' => true])]);

        $decision = app(Publisher::class)->activate(
            app(Publisher::class)->proposeActivation($campaign, 'tester'),
            'tester',
        );

        $this->assertTrue($decision->wasApplied());
        $this->assertTrue($campaign->fresh()->isRunning());
        $this->assertNotNull($campaign->fresh()->activated_at);
    }

    /**
     * A platform failure leaves it recorded as not running.
     *
     * The direction that matters. A campaign recorded as running that is not shows
     * up as somebody asking why nothing is spending; the reverse shows up as nobody
     * asking anything, while money moves.
     */
    public function test_a_failed_activation_is_not_recorded_as_running(): void
    {
        $campaign = $this->publishedCampaign();

        Http::fake(['*' => Http::response(['error' => ['message' => 'Too many calls']], 400)]);

        try {
            app(Publisher::class)->activate(
                app(Publisher::class)->proposeActivation($campaign, 'tester'),
                'tester',
            );
            $this->fail('a failing platform was reported as a successful activation');
        } catch (\Throwable) {
            // expected
        }

        $this->assertFalse($campaign->fresh()->isRunning());
        $this->assertNull($campaign->fresh()->activated_at);
    }

    // ------------------------------------------------------------- the refusals

    /** An unpublished campaign has nothing to start. */
    public function test_an_unpublished_campaign_cannot_be_started(): void
    {
        $problems = app(Publisher::class)->activationProblems($this->campaign());

        $this->assertNotEmpty($problems);
        $this->assertStringContainsString('not been published', implode(' ', $problems));
    }

    /** A running one is not started twice. */
    public function test_a_running_campaign_cannot_be_started_again(): void
    {
        $campaign = $this->publishedCampaign();
        $campaign->update(['activated_at' => now()]);

        $problems = app(Publisher::class)->activationProblems($campaign->fresh());

        $this->assertStringContainsString('already running', implode(' ', $problems));
    }

    /**
     * And the gate is asked again, minus the one question it disagrees on.
     *
     * check() refuses a campaign that is already on the platform, which is every
     * campaign this can be asked about - so reusing it wholesale would refuse
     * everything. soundness() is the shared part.
     */
    public function test_a_campaign_that_has_lost_something_cannot_be_started(): void
    {
        $campaign = $this->publishedCampaign();
        $campaign->update(['landing_url' => null]);

        $problems = app(Publisher::class)->activationProblems($campaign->fresh());

        $this->assertStringContainsString('landing page URL not set', implode('; ', $problems));
        $this->assertStringNotContainsString('already published', implode('; ', $problems));
    }

    // -------------------------------------------------------------- stopping it

    /** Stopping works, and is recorded. */
    public function test_a_running_campaign_can_be_stopped(): void
    {
        $campaign = $this->publishedCampaign();
        $campaign->update(['activated_at' => now()]);

        Http::fake(['*' => Http::response(['success' => true])]);

        app(Publisher::class)->pause($campaign->fresh(), 'tester');

        $this->assertFalse($campaign->fresh()->isRunning());
        Http::assertSent(fn ($request): bool => ($request->data()['status'] ?? null) === 'PAUSED');
    }

    /** Stopping something already stopped does nothing and says nothing went wrong. */
    public function test_stopping_a_paused_campaign_is_not_an_error(): void
    {
        $campaign = $this->publishedCampaign();

        Http::fake();

        app(Publisher::class)->pause($campaign, 'tester');

        Http::assertNothingSent();
        $this->assertFalse($campaign->fresh()->isRunning());
    }

    // --------------------------------------------------------- the chat's side

    /**
     * Starting a campaign always pauses for a human.
     *
     * Asked of the wrapped tool, not the wrapper. Every tool in the registry is a
     * NamespacedTool, which implements Approvable in order to forward the question -
     * so `instanceof` on what the registry hands back is true for all of them and
     * asserts nothing. That forwarding exists because without it the pause was
     * skipped entirely and publishing ran unapproved.
     */
    public function test_the_activate_tool_requires_approval(): void
    {
        $this->assertInstanceOf(
            Approvable::class,
            $this->tool('campaign__activate_campaign')->inner(),
            'the tool that starts spending is not approval-gated',
        );

        // And it says yes, rather than merely being able to be asked.
        $this->assertNotNull(
            $this->tool('campaign__activate_campaign')->shouldRequestApproval(new Request),
            'starting a campaign does not actually pause for approval',
        );
    }

    /**
     * Stopping does not.
     *
     * Approvals stand between a buyer and their money leaving. This is the
     * direction that stops it, and asking somebody to confirm twice before they can
     * halt a campaign is a way to lose money.
     */
    public function test_the_pause_tool_does_not_require_approval(): void
    {
        $this->assertNotInstanceOf(Approvable::class, $this->tool('campaign__pause_campaign')->inner());
        $this->assertNull($this->tool('campaign__pause_campaign')->shouldRequestApproval(new Request));
    }

    /** The request tool puts the cost on screen before anybody agrees to it. */
    public function test_the_request_says_what_it_will_cost(): void
    {
        $campaign = $this->publishedCampaign();
        app(Workspace::class)->bindTo('conv-activate');
        app(Workspace::class)->focusOn($campaign);

        $result = json_decode((string) $this->tool('campaign__request_activation')->handle(new Request), true);

        $this->assertTrue($result['ok'], $result['error'] ?? '');

        $offered = collect(app(Choices::class)->all())->firstWhere('field', 'activation_approval');

        $this->assertNotNull($offered, 'nothing was put on screen to approve');
        $this->assertStringContainsString('spending', $offered['options'][0]['hint']);
        $this->assertStringContainsString('25', $offered['options'][0]['hint']);
    }

    /** And it refuses a campaign that has not been published. */
    public function test_the_request_refuses_an_unpublished_campaign(): void
    {
        app(Workspace::class)->bindTo('conv-activate-draft');
        app(Workspace::class)->focusOn($this->campaign());

        $result = json_decode((string) $this->tool('campaign__request_activation')->handle(new Request), true);

        $this->assertFalse($result['ok']);
        $this->assertStringContainsString('not been published', $result['error']);
    }

    private function tool(string $name): NamespacedTool
    {
        $tool = collect(app(ToolRegistry::class)->resolve())->first(fn ($t) => $t->name() === $name);

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

        return $tool;
    }

    private function campaign(array $attributes = []): Campaign
    {
        $user = User::factory()->create();
        $this->actingAs($user);

        return Campaign::create([
            'user_id' => $user->id,
            'name' => 'Winter Sale',
            'platform' => 'meta',
            'status' => 'draft',
            'objective' => 'OUTCOME_TRAFFIC',
            'ad_account_id' => 'act_111',
            'page_id' => 'page-1',
            'landing_url' => 'https://example.com/offer',
            'budget' => 25,
            'budget_mode' => 'daily',
            'currency' => 'USD',
            'locations' => ['countries' => ['US']],
            'placements' => ['facebook'],
            'special_ad_categories' => ['NONE'],
            'utm' => '',
            ...$attributes,
        ]);
    }

    /** A campaign as it is the moment after a successful publish: built and parked. */
    private function publishedCampaign(): Campaign
    {
        $campaign = $this->campaign([
            'status' => 'published',
            'external_campaign_id' => '120252047635070626',
            'external_ids' => ['campaign_id' => '120252047635070626', 'adset_id' => 'adset-1'],
        ]);

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

        AssetPlatformRef::create([
            'asset_id' => $asset->id, 'platform' => 'meta',
            'ad_account_id' => 'act_111', 'external_hash' => 'HASH123',
        ]);

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

        return $campaign->fresh();
    }
}
