<?php

namespace Tests\Feature;

use App\Agent\ToolRegistry;
use App\Agent\Workspace;
use App\Campaigns\CampaignUpdater;
use App\Campaigns\PendingChanges;
use App\Campaigns\Publisher;
use App\Models\ApiCall;
use App\Models\Asset;
use App\Models\Campaign;
use App\Models\CampaignChange;
use App\Models\User;
use App\Services\Meta\Meta;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Laravel\Ai\Tools\Request;
use Tests\TestCase;

/**
 * Editing a campaign that is already live.
 *
 * The failure this exists to prevent: an edit updates our copy, the platform
 * keeps the old values, the screen shows the new ones, and nothing tells anyone
 * they disagree. Every test below is a way that could still happen.
 */
class CampaignEditTest extends TestCase
{
    use RefreshDatabase;

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

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

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

    public function test_a_budget_change_after_publish_is_reported_as_pending(): void
    {
        $campaign = $this->publishedCampaign();

        $this->assertSame([], app(PendingChanges::class)->updatable($campaign));

        $this->runTool('campaign__set_campaign_budget', ['daily_budget' => 30]);

        $pending = app(PendingChanges::class)->updatable($campaign->fresh());

        $this->assertArrayHasKey('budget', $pending);
        $this->assertSame('30.00', $pending['budget']['to']);
    }

    public function test_pushing_a_change_sends_it_to_the_platform_and_clears_the_difference(): void
    {
        $campaign = $this->publishedCampaign();
        $this->runTool('campaign__set_campaign_budget', ['daily_budget' => 30]);

        $updater = app(CampaignUpdater::class);
        $change = $updater->apply($updater->propose($campaign->fresh(), 'test'), 'test');

        $this->assertSame('applied', $change->state);

        // Budget lives on the ad set, in cents. The url carries the id; the
        // entity type is what Meta rejected when it was sent as "adset".
        Http::assertSent(fn ($request) => str_contains($request->url(), 'adset-1')
            && $request['daily_budget'] === 3000);

        $this->assertSame([], app(PendingChanges::class)->updatable($campaign->fresh()));
    }

    /** Meta treats an ad's creative as immutable, so this is not an edit. */
    public function test_changing_ad_copy_is_refused_rather_than_half_applied(): void
    {
        $campaign = $this->publishedCampaign();

        $this->runTool('campaign__write_ad_copy', ['ads' => [
            ['headline' => 'Completely different', 'primary_text' => 'new words', 'cta' => 'SHOP_NOW'],
        ]]);

        $rebuild = app(PendingChanges::class)->requiringRebuild($campaign->fresh());

        $this->assertNotEmpty($rebuild);
        $this->assertStringContainsString('new creative and a new ad', $rebuild[0]);

        $result = $this->runTool('campaign__push_changes', []);

        $this->assertFalse($result['ok']);
        $this->assertSame(0, CampaignChange::where('state', 'applied')->count());
    }

    public function test_a_second_change_within_the_cooldown_is_refused(): void
    {
        $campaign = $this->publishedCampaign();
        $updater = app(CampaignUpdater::class);

        $this->runTool('campaign__set_campaign_budget', ['daily_budget' => 30]);
        $this->assertSame('applied', $updater->apply($updater->propose($campaign->fresh(), 'test'), 'test')->state);

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

        $this->runTool('campaign__set_campaign_budget', ['daily_budget' => 35]);
        $second = $updater->apply($updater->propose($campaign->fresh(), 'test'), 'test');

        $this->assertSame('failed', $second->state);
        $this->assertStringContainsString('limited to one every', $second->error);
        $this->assertSame($callsSoFar, ApiCall::where('mutating', true)->count());
    }

    public function test_the_kill_switch_stops_every_write_without_a_deploy(): void
    {
        $campaign = $this->publishedCampaign();
        $this->runTool('campaign__set_campaign_budget', ['daily_budget' => 30]);

        config(['platforms.guardrails.paused' => true]);

        $updater = app(CampaignUpdater::class);
        $change = $updater->apply($updater->propose($campaign->fresh(), 'test'), 'test');

        $this->assertSame('failed', $change->state);
        $this->assertStringContainsString('paused', $change->error);
    }

    public function test_review_changes_never_claims_a_live_campaign_is_in_sync_when_it_is_not(): void
    {
        $campaign = $this->publishedCampaign();
        $this->runTool('campaign__set_campaign_budget', ['daily_budget' => 30]);

        $result = $this->runTool('campaign__review_changes', []);

        $this->assertTrue($result['published']);
        $this->assertArrayHasKey('budget', $result['pending_changes']);
    }

    public function test_publishing_records_what_the_platform_holds(): void
    {
        $campaign = $this->publishedCampaign();

        $this->assertNotNull($campaign->published_state);
        $this->assertSame('20.00', $campaign->published_state['budget']);
        $this->assertNotNull($campaign->last_synced_at);
    }

    /**
     * Meta reads an unqualified date string in the ad account's own timezone,
     * which is frequently not ours, so an offset is not cosmetic: without one
     * the campaign starts hours from when the user asked.
     */
    public function test_the_schedule_reaches_the_platform_with_a_timezone(): void
    {
        $campaign = $this->publishedCampaign();

        $this->runTool('campaign__set_schedule', [
            'starts_at' => '2026-10-01T09:00:00Z',
            'ends_at' => '2026-10-15T09:00:00Z',
        ]);

        $updater = app(CampaignUpdater::class);
        $change = $updater->apply($updater->propose($campaign->fresh(), 'test'), 'test');

        $this->assertSame('applied', $change->state);

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

            return str_contains((string) $request['start_time'], '2026-10-01T09:00:00+00:00')
                && str_contains((string) $request['end_time'], '2026-10-15T09:00:00+00:00');
        });
    }

    /** Dates were validated and shown, then silently dropped on create. */
    public function test_publishing_sends_the_schedule_rather_than_dropping_it(): void
    {
        $this->fakeMeta();
        Storage::fake('local');
        Storage::disk('local')->put('assets/creative.png', 'bytes');

        $created = $this->runTool('campaign__start_campaign', ['name' => 'Scheduled', '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'], 'placements' => ['facebook']]);
        $this->runTool('campaign__set_schedule', ['starts_at' => '2026-11-01T00:00:00Z']);
        $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']);

        $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' => 'Scheduled', 'primary_text' => 'Soon.', 'cta' => 'SHOP_NOW'],
        ]]);

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

        Http::assertSent(fn ($request) => str_contains($request->url(), '/adsets')
            && str_contains((string) $request['start_time'], '2026-11-01T00:00:00+00:00'));
    }

    /**
     * Meta accepts campaign, ad_set or ad as an entity type and rejects
     * anything else outright. Our own key for the same thing is adset_id, and
     * carrying that name through was rejected with "Invalid entity_type: adset"
     * the first time a real update ran. The Graph path never sends a type, so
     * only the MCP path can catch it.
     */
    public function test_the_update_names_the_entity_type_meta_expects(): void
    {
        // Publish over Graph as the other tests do, then switch the transport
        // so only the update goes through the MCP, which is the one that
        // carries an entity type.
        $campaign = $this->publishedCampaign();
        $this->runTool('campaign__set_campaign_budget', ['daily_budget' => 9]);

        config([
            'platforms.meta.mcp.enabled' => true,
            'platforms.meta.mcp.token' => 'test-mcp-token',
        ]);
        app()->forgetInstance(Meta::class);

        Http::fake([
            'mcp.facebook.com/*' => Http::response(
                'data: '.json_encode([
                    'jsonrpc' => '2.0',
                    'id' => 1,
                    'result' => ['content' => [['type' => 'text', 'text' => '{"success":true}']]],
                ])."\n",
            ),
        ]);

        $updater = app(CampaignUpdater::class);
        $updater->apply($updater->propose($campaign->fresh(), 'test'), 'test');

        Http::assertSent(function ($request) {
            $arguments = $request->data()['params']['arguments'] ?? [];

            return str_contains($request->url(), 'mcp.facebook.com')
                && ($arguments['entity_type'] ?? null) === 'ad_set';
        });
    }

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

    private function publishedCampaign(): Campaign
    {
        $this->fakeMeta();
        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'], 'placements' => ['facebook']]);
        $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']);

        $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.', 'cta' => 'SHOP_NOW'],
        ]]);

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

        return $campaign->fresh();
    }

    private function fakeMeta(array $overrides = []): void
    {
        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}].");
    }
}
