<?php

namespace Tests\Feature;

use App\Agent\ToolRegistry;
use App\Agent\Workspace;
use App\Models\ArtifactFieldSource;
use App\Models\Asset;
use App\Models\Campaign;
use App\Models\ConversationArtifact;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Tests\TestCase;

/**
 * The agent core: a conversation is a workspace, not a campaign.
 *
 * These cover the three behaviours everything else is built on, so that adding
 * creative, landing page or analysis tools cannot quietly break them.
 */
class AgentWorkspaceTest extends TestCase
{
    use RefreshDatabase;

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

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

    public function test_one_conversation_can_hold_several_campaigns(): void
    {
        $first = $this->runTool('campaign__start_campaign', ['name' => 'Winter Sale', 'platform' => 'meta']);
        $second = $this->runTool('campaign__start_campaign', ['name' => 'Lead Gen Q4', 'platform' => 'google']);

        $this->assertTrue($first['ok']);
        $this->assertTrue($second['ok']);
        $this->assertNotSame($first['campaign_id'], $second['campaign_id']);

        $this->assertSame(2, ConversationArtifact::where('conversation_id', 'conv-test-0001')->count());
    }

    public function test_creating_a_campaign_focuses_it_and_edits_land_on_the_right_row(): void
    {
        $first = $this->runTool('campaign__start_campaign', ['name' => 'Winter Sale', 'platform' => 'meta']);
        $second = $this->runTool('campaign__start_campaign', ['name' => 'Lead Gen Q4', 'platform' => 'google']);

        // The most recently created campaign is in focus, so this budget is its.
        $this->assertTrue($this->runTool('campaign__set_campaign_budget', ['daily_budget' => 15])['ok']);

        $this->runTool('workspace__switch_focus', ['kind' => 'Campaign', 'id' => $first['campaign_id']]);
        $this->assertTrue($this->runTool('campaign__set_campaign_budget', ['daily_budget' => 20])['ok']);

        $this->assertSame('20.00', Campaign::find($first['campaign_id'])->budget);
        $this->assertSame('15.00', Campaign::find($second['campaign_id'])->budget);
    }

    public function test_a_budget_above_the_ceiling_is_refused(): void
    {
        config(['agent.max_daily_budget' => 20]);

        $campaign = $this->runTool('campaign__start_campaign', ['name' => 'Winter Sale', 'platform' => 'meta']);
        $result = $this->runTool('campaign__set_campaign_budget', ['daily_budget' => 500]);

        $this->assertFalse($result['ok']);
        $this->assertStringContainsString('ceiling', $result['error']);
        $this->assertNull(Campaign::find($campaign['campaign_id'])->budget);
    }

    public function test_editing_with_nothing_in_focus_is_refused_rather_than_guessed(): void
    {
        $result = $this->runTool('campaign__set_campaign_budget', ['daily_budget' => 15]);

        $this->assertFalse($result['ok']);
        $this->assertStringContainsString('No campaign is in focus', $result['error']);
    }

    public function test_an_unsupported_platform_is_refused_with_the_supported_list(): void
    {
        $result = $this->runTool('campaign__start_campaign', ['name' => 'X', 'platform' => 'snapchat']);

        $this->assertFalse($result['ok']);
        $this->assertContains('meta', $result['supported']);
        $this->assertSame(0, Campaign::count());
    }

    public function test_switching_focus_to_something_outside_the_conversation_is_refused(): void
    {
        $this->runTool('campaign__start_campaign', ['name' => 'Winter Sale', 'platform' => 'meta']);

        $result = $this->runTool('workspace__switch_focus', ['kind' => 'Campaign', 'id' => 9999]);

        $this->assertFalse($result['ok']);
    }

    public function test_stated_and_inferred_values_are_recorded_separately(): void
    {
        $created = $this->runTool('campaign__start_campaign', [
            'name' => 'Winter Sale',
            'platform' => 'meta',
            'objective' => 'OUTCOME_SALES',
        ]);

        $sources = app(Workspace::class)->sourcesFor(Campaign::find($created['campaign_id']));

        $this->assertSame(ArtifactFieldSource::USER_STATED, $sources['name']);
        $this->assertSame(ArtifactFieldSource::USER_STATED, $sources['platform']);
        $this->assertSame(ArtifactFieldSource::MODEL_INFERRED, $sources['objective']);
    }

    public function test_an_omitted_optional_argument_does_not_error(): void
    {
        $result = $this->runTool('campaign__start_campaign', ['name' => 'No Objective', 'platform' => 'meta']);

        $this->assertTrue($result['ok']);
        $this->assertArrayNotHasKey('objective', app(Workspace::class)->sourcesFor(
            Campaign::find($result['campaign_id'])
        ));
    }

    public function test_tools_are_exposed_under_their_namespace(): void
    {
        $names = array_map(
            fn (Tool $tool): string => $tool->name(),
            app(ToolRegistry::class)->resolve(),
        );

        $this->assertContains('campaign__start_campaign', $names);
        $this->assertContains('workspace__switch_focus', $names);
    }

    /**
     * OpenAI rejects anything outside [a-zA-Z0-9_-] in a tool name, and does so
     * with a 400 at request time rather than at boot. A dotted separator worked
     * fine on Gemini and broke the moment the provider changed, so the rule is
     * asserted here rather than rediscovered.
     */
    public function test_every_tool_name_is_accepted_by_every_provider(): void
    {
        foreach (app(ToolRegistry::class)->resolve() as $tool) {
            $this->assertMatchesRegularExpression(
                '/^[a-zA-Z0-9_-]{1,64}$/',
                $tool->name(),
                "Tool name [{$tool->name()}] would be rejected by OpenAI.",
            );
        }
    }

    /**
     * Copy is usually written before the user has uploaded anything, so the
     * pairing has to be settable afterwards. Without this the ads keep no
     * creative and the gate refuses for a reason nothing can fix.
     */
    public function test_creatives_can_be_attached_to_ads_after_the_copy_is_written(): void
    {
        $created = $this->runTool('campaign__start_campaign', ['name' => 'Winter Sale', 'platform' => 'meta']);
        $this->runTool('campaign__write_ad_copy', ['ads' => [
            ['headline' => 'One', 'primary_text' => 'a', 'cta' => 'SHOP_NOW'],
            ['headline' => 'Two', 'primary_text' => 'b', 'cta' => 'SHOP_NOW'],
        ]]);

        $campaign = Campaign::find($created['campaign_id']);
        $this->assertNull($campaign->ads()->first()->asset_id);

        $asset = Asset::create(['kind' => 'image', 'source' => 'upload', 'path' => 'assets/a.png']);
        app(Workspace::class)->produced($asset);
        app(Workspace::class)->focusOn($campaign);

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

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

        // One image across both ads: the same asset may back several ads.
        $this->assertSame([$asset->id, $asset->id], $campaign->ads()->orderBy('id')->pluck('asset_id')->all());
    }

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

        $asset = Asset::create(['kind' => 'image', 'source' => 'upload', 'path' => 'assets/a.png']);
        app(Workspace::class)->produced($asset);
        app(Workspace::class)->focusOn($campaign);

        $this->runTool('campaign__write_ad_copy', ['ads' => [
            ['headline' => 'One', 'primary_text' => 'a', 'cta' => 'SHOP_NOW'],
        ]]);
        $this->runTool('campaign__attach_creatives', []);

        $this->runTool('campaign__write_ad_copy', ['ads' => [
            ['headline' => 'Rewritten', 'primary_text' => 'b', 'cta' => 'LEARN_MORE'],
        ]]);

        $ad = $campaign->ads()->first();
        $this->assertSame('Rewritten', $ad->headline);
        $this->assertSame($asset->id, $ad->asset_id);
    }

    public function test_attaching_an_asset_from_another_conversation_is_refused(): void
    {
        $this->runTool('campaign__start_campaign', ['name' => 'Winter Sale', 'platform' => 'meta']);
        $this->runTool('campaign__write_ad_copy', ['ads' => [
            ['headline' => 'One', 'primary_text' => 'a', 'cta' => 'SHOP_NOW'],
        ]]);

        $mine = Asset::create(['kind' => 'image', 'source' => 'upload', 'path' => 'assets/a.png']);
        app(Workspace::class)->produced($mine);

        // Attaching focuses the asset, so the campaign has to be put back.
        // The chat controller does the same after handling uploads.
        app(Workspace::class)->focusOn(Campaign::first());

        $stranger = Asset::create(['kind' => 'image', 'source' => 'upload', 'path' => 'assets/b.png']);

        $result = $this->runTool('campaign__attach_creatives', [
            'mapping' => [['ad_id' => 1, 'asset_id' => $stranger->id]],
        ]);

        $this->assertFalse($result['ok']);
        $this->assertStringContainsString('not attached to this conversation', $result['error']);
    }

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