<?php

namespace Tests\Feature;

use App\Agent\ToolRegistry;
use App\Agent\Tools\Campaign\PublishCampaign;
use App\Agent\Workspace;
use App\Campaigns\Publisher;
use App\Campaigns\PublishGate;
use App\Models\ApiCall;
use App\Models\Asset;
use App\Models\Campaign;
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\TestCase;

/**
 * The whole path a chat takes to a live campaign, with the platform faked.
 *
 * Covers what the product is for, and the two things that must never regress:
 * the gate refusing an incomplete campaign, and a mid-flight failure leaving
 * nothing behind on the account.
 */
class CampaignPublishTest extends TestCase
{
    use RefreshDatabase;

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

        config([
            'platforms.guardrails.allowed_ad_accounts' => ['act_111'],
            'platforms.guardrails.max_daily_budget' => 20,
            'platforms.guardrails.max_calls_per_hour' => 60,
            'platforms.meta.mcp.enabled' => false,   // exercise the Graph path
            'platforms.meta.token' => 'test-token',
        ]);

        $this->actingAs(User::factory()->create());
        app(Workspace::class)->bindTo('conv-publish-0001');
    }

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

        $campaign = $this->assembleReadyCampaign();

        $this->assertSame([], app(PublishGate::class)->check($campaign));

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

        $this->assertTrue($decision->wasApplied());
        $this->assertSame('published', $campaign->fresh()->status);
        $this->assertSame('camp-1', $campaign->fresh()->external_campaign_id);
        $this->assertSame('published', $campaign->ads()->first()->status);

        // Every mutation reached the audit log, dry run included.
        $this->assertTrue(ApiCall::where('dry_run', true)->exists());
        $this->assertTrue(ApiCall::where('endpoint', 'act_111/campaigns')->where('mutating', true)->exists());
    }

    /**
     * Two routes can publish: the chat tool and the panel button. Publishing
     * from one leaves the other's page stale and still enabled, and a second
     * run created a whole second campaign on the account while external_ids
     * kept only the last, orphaning the first.
     */
    public function test_a_campaign_cannot_be_published_twice(): void
    {
        $this->fakeMeta();

        $campaign = $this->assembleReadyCampaign();
        $publisher = app(Publisher::class);

        $first = $publisher->approve($publisher->propose($campaign, 'chat'), 'chat');
        $this->assertTrue($first->wasApplied());

        $callsAfterFirst = ApiCall::where('mutating', true)->count();

        $second = $publisher->approve($publisher->propose($campaign->fresh(), 'web'), 'web');

        $this->assertFalse($second->wasApplied());
        $this->assertStringContainsString('already published', $second->reason);

        // The decisive assertion: nothing further was created on the platform.
        $this->assertSame($callsAfterFirst, ApiCall::where('mutating', true)->count());
        $this->assertSame('camp-1', $campaign->fresh()->external_campaign_id);
    }

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

        $campaign = $this->assembleReadyCampaign();
        $publisher = app(Publisher::class);
        $publisher->approve($publisher->propose($campaign, 'chat'), 'chat');

        $blocking = app(PublishGate::class)->check($campaign->fresh());

        $this->assertNotEmpty($blocking);
        $this->assertStringContainsString('already published', $blocking[0]);
    }

    public function test_the_gate_refuses_an_incomplete_campaign(): void
    {
        $created = $this->runTool('campaign__start_campaign', ['name' => 'Bare', 'platform' => 'meta']);
        $campaign = Campaign::find($created['campaign_id']);

        $blocking = app(PublishGate::class)->check($campaign);

        $this->assertContains('objective not set', $blocking);
        $this->assertContains('landing page URL not set', $blocking);
        $this->assertContains('ad account not verified', $blocking);
        $this->assertContains('no ad copy written yet', $blocking);
    }

    public function test_a_failure_part_way_through_removes_what_was_already_created(): void
    {
        // Campaign and ad set succeed, the creative fails. Nothing may survive.
        $this->fakeMeta([
            '*/act_111/adcreatives' => Http::response(
                ['error' => ['message' => 'Invalid image hash', 'code' => 100]], 400,
            ),
        ]);

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

        try {
            $publisher->approve($decision, 'test');
            $this->fail('Publishing should have failed.');
        } catch (\Throwable) {
            // expected
        }

        $this->assertSame('failed', $campaign->fresh()->status);

        // Deepest first, and both objects that existed were deleted.
        $deleted = ApiCall::where('method', 'DELETE')->pluck('endpoint')->all();
        $this->assertSame(['adset-1', 'camp-1'], $deleted);
    }

    public function test_an_account_outside_the_allow_list_is_refused_before_any_call(): void
    {
        Http::fake();

        $this->runTool('campaign__start_campaign', ['name' => 'Rogue', 'platform' => 'meta']);
        $result = $this->runTool('campaign__verify_ad_account', ['ad_account_id' => '999']);

        $this->assertFalse($result['ok']);
        $this->assertStringContainsString('not on the allowed list', $result['error']);
        Http::assertNothingSent();
    }

    /**
     * The one tool that spends money must never run on the model's say-so.
     * The package pauses the run when a tool asks for approval, so this is the
     * lock that makes publishing from the chat safe.
     */
    public function test_the_publish_tool_always_asks_for_approval(): void
    {
        $approval = app(PublishCampaign::class)->shouldRequestApproval(new Request([]));

        $this->assertNotNull($approval, 'publish_campaign must require approval before running.');
    }

    public function test_publishing_from_chat_is_refused_while_the_gate_blocks(): void
    {
        Http::fake();

        $this->runTool('campaign__start_campaign', ['name' => 'Bare', 'platform' => 'meta']);
        $result = $this->runTool('campaign__publish_campaign', []);

        $this->assertFalse($result['ok']);
        $this->assertFalse($result['published']);
        $this->assertContains('objective not set', $result['still_needed']);
        Http::assertNothingSent();
    }

    // ----------------------------------------------------------------- helpers

    private function assembleReadyCampaign(): Campaign
    {
        Storage::fake('local');
        Storage::disk('local')->put('assets/creative.png', 'bytes');

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

        $this->runTool('campaign__set_campaign_basics', [
            'objective' => 'OUTCOME_SALES',
            'landing_url' => 'https://example.com/offer',
        ]);
        $this->runTool('campaign__set_campaign_budget', ['daily_budget' => 20]);
        $this->runTool('campaign__set_targeting', [
            'countries' => ['US'],
            'devices' => ['mobile'],
            'placements' => ['facebook', 'instagram'],
        ]);
        $this->runTool('campaign__set_bidding', ['strategy' => 'COST_CAP', 'target_cost' => 0.5]);
        $this->runTool('campaign__verify_ad_account', ['ad_account_id' => '111']);
        $this->runTool('campaign__choose_page', ['page_id' => 'page-1']);
        $this->runTool('campaign__choose_pixel', ['pixel_id' => 'pixel-1']);

        // An upload is an asset; it becomes advertising when paired with copy.
        $asset = Asset::create([
            'kind' => 'image',
            'source' => 'upload',
            'path' => 'assets/creative.png',
            'original_name' => 'creative.png',
            'mime' => 'image/png',
            'bytes' => 5,
        ]);
        app(Workspace::class)->produced($asset);
        app(Workspace::class)->focusOn($campaign);

        $this->runTool('campaign__review_assets', []);
        $this->runTool('campaign__write_ad_copy', ['ads' => [[
            'headline' => 'Winter Sale',
            'primary_text' => 'Half price until Sunday.',
            'cta' => 'SHOP_NOW',
        ]]]);

        return $campaign->fresh();
    }

    /**
     * @param  array<string,mixed>  $overrides  win on key collision, so a test can
     *                                          make one endpoint fail and leave the rest
     */
    private function fakeMeta(array $overrides = []): void
    {
        // Union, not spread: a duplicate key in an array literal is silently
        // won by the later entry, which would drop the override entirely.
        Http::fake($overrides + [
            '*/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']]]),
            '*/me/accounts*' => Http::response(['data' => [['id' => 'page-1', 'name' => 'Test Page']]]),
            '*/adspixels*' => Http::response(['data' => [['id' => 'pixel-1', 'name' => 'Test Pixel']]]),
            '*/act_111*' => Http::response([
                'id' => 'act_111', 'name' => 'Test Account',
                'account_status' => 1, 'currency' => 'USD',
            ]),
            '*' => Http::response(['success' => true]),
        ]);
    }

    /** @return array<string,mixed> */
    private function runTool(string $name, array $arguments): array
    {
        foreach (app(ToolRegistry::class)->resolve() as $tool) {
            if ($tool->name() === $name) {
                return json_decode((string) $tool->handle(new Request($arguments)), true);
            }
        }

        $this->fail("No tool registered as [{$name}].");
    }
}
