<?php

namespace Tests\Feature;

use App\Agent\ToolRegistry;
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\Contracts\Approvable;
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());
    }

    /**
     * A retired placement position fails the whole ad set.
     *
     * facebook_positions carried video_feeds, which Meta has retired and now
     * refuses rather than ignores: "Facebook video feeds placement is
     * deprecated for this API version and cannot be selected" (subcode
     * 2490562). Naming any publisher platform switches off automatic placement
     * and forces these lists to be sent, so one dead position was enough to
     * make every campaign choosing Facebook fail at the ad set. Found by
     * publishing a real draft, not by this suite, which is why it is here now.
     */
    public function test_no_retired_placement_position_is_sent(): void
    {
        $this->fakeMeta();

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

        $publisher->approve($publisher->propose($campaign, 'chat'), 'chat');

        Http::assertSent(function ($request): bool {
            if (! str_contains($request->url(), '/adsets')) {
                return false;
            }

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

            return ($targeting['facebook_positions'] ?? []) === ['feed', 'story', 'facebook_reels', 'marketplace', 'search']
                && ! in_array('video_feeds', $targeting['facebook_positions'] ?? [], true);
        });
    }

    /**
     * The tracking has to be on the URL that actually reaches Meta.
     *
     * Storing it and not sending it would look identical everywhere except the
     * analytics, weeks later, on traffic that has already been paid for.
     */
    public function test_the_destination_url_sent_to_meta_carries_the_tracking(): void
    {
        $this->fakeMeta();

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

        $publisher->approve($publisher->propose($campaign, 'chat'), 'chat');

        Http::assertSent(function ($request): bool {
            if (! str_contains($request->url(), '/adcreatives')) {
                return false;
            }

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

            return $link === 'https://example.com/offer?utm_source=facebook&utm_medium=paid_social'
                // The call to action points at the same place, or a click on the
                // button lands untracked while a click on the image does not.
                && ($spec['link_data']['call_to_action']['value']['link'] ?? null) === $link;
        });
    }

    /**
     * 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.
     */
    /**
     * Asserted through the registry, not on the tool directly.
     *
     * The agent only ever sees the namespaced wrapper, and the run loop decides
     * whether to pause by testing that wrapper for Approvable. Checking the
     * inner tool passed while the real path executed publish with no pause at
     * all, which is how this shipped unnoticed until a browser run caught it.
     */
    public function test_every_tool_that_spends_money_asks_for_approval_through_the_registry(): void
    {
        $gated = ['campaign__publish_campaign', 'campaign__push_changes'];
        $seen = [];

        foreach (app(ToolRegistry::class)->resolve() as $tool) {
            if (! in_array($tool->name(), $gated, true)) {
                continue;
            }

            $seen[] = $tool->name();

            $this->assertInstanceOf(
                Approvable::class,
                $tool,
                "[{$tool->name()}] is not Approvable as the agent sees it, so the run will never pause.",
            );

            $this->assertNotNull(
                $tool->shouldRequestApproval(new Request([])),
                "[{$tool->name()}] must require approval before it runs.",
            );
        }

        $this->assertSame($gated, $seen, 'A tool that spends money is missing from the registry.');
    }

    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',
        ]);
        // The gate requires this to be answered, not defaulted.
        $this->runTool('campaign__declare_ad_category', ['categories' => ['NONE']]);
        $this->runTool('campaign__set_campaign_budget', ['amount' => 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__set_tracking', ['utm' => 'utm_source=facebook&utm_medium=paid_social']);
        $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']);
        // Instagram is one of the placements above, so the account has to be
        // answered. The fake returns none, which records "runs under the Page".
        $this->runTool('campaign__choose_instagram_account', []);

        // 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();
    }

    /**
     * A generated creative must upload from the disk it was written to.
     *
     * Uploads live on the default disk and generated creatives on the public
     * one, and the upload read the local disk for everything. Nothing caught it
     * because every test asset was an upload; a generated one failed only at
     * the call to Meta, long after the point that was actually wrong.
     */
    public function test_an_asset_on_the_public_disk_uploads_from_that_disk(): void
    {
        Storage::fake('public');
        Storage::disk('public')->put('creatives/images/made.png', 'generated-bytes');

        $this->fakeMeta();

        $campaign = Campaign::create([
            'user_id' => auth()->id(),
            'name' => 'Generated creative',
            'platform' => 'meta',
            'status' => 'draft',
            'ad_account_id' => 'act_111',
        ]);

        $asset = Asset::create([
            'kind' => 'image',
            'source' => 'generated',
            'disk' => 'public',
            'path' => 'creatives/images/made.png',
            'original_name' => 'creative-1.png',
            'mime' => 'image/png',
            'bytes' => 15,
        ]);

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

        $this->runTool('campaign__review_assets', []);

        $this->assertTrue(
            $asset->fresh()->platformRefs()->where('ad_account_id', 'act_111')->exists(),
            'The generated asset should have uploaded and recorded a platform ref.',
        );
    }

    /**
     * @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}].");
    }
}
