<?php

namespace Tests\Feature;

use App\Agent\Tools\Campaign\SetCampaignBudget;
use App\Agent\Workspace;
use App\Campaigns\PublishGate;
use App\Models\Campaign;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Ai\Tools\Request as ToolRequest;
use Tests\TestCase;

/**
 * The checks that stop a campaign being published wrong.
 *
 * Each of these covers something the platform accepts and then gets wrong
 * quietly: a ceiling that means a different amount on every account, copy that
 * is truncated rather than refused, a placement the objective cannot use, an
 * Instagram ad running under a Facebook Page, and tracking that breaks every
 * click. None of them fail loudly at publish, which is why they are here.
 */
class CampaignValidationTest extends TestCase
{
    use RefreshDatabase;

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

        config([
            'platforms.guardrails.allowed_ad_accounts' => ['act_777'],
            'platforms.guardrails.budget_ceiling_currency' => 'USD',
            'platforms.guardrails.max_daily_budget' => 20,
            'platforms.guardrails.max_daily_budget_by_currency' => ['INR' => 1700.0],
        ]);

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

    private function campaign(array $attributes = []): Campaign
    {
        return Campaign::create(array_merge([
            'user_id' => auth()->id(),
            'name' => 'Winter Sale',
            'platform' => 'meta',
            'status' => 'draft',
            'objective' => 'OUTCOME_TRAFFIC',
            'currency' => 'USD',
            'budget' => 10,
        ], $attributes));
    }

    /** @return list<string> */
    private function problems(Campaign $campaign): array
    {
        return app(PublishGate::class)->check($campaign->fresh());
    }

    private function assertBlocks(Campaign $campaign, string $fragment): void
    {
        $this->assertNotEmpty(
            array_filter($this->problems($campaign), fn (string $p): bool => str_contains($p, $fragment)),
            "Expected a problem containing [{$fragment}]. Got: ".json_encode($this->problems($campaign)),
        );
    }

    private function assertAllows(Campaign $campaign, string $fragment): void
    {
        $this->assertEmpty(
            array_filter($this->problems($campaign), fn (string $p): bool => str_contains($p, $fragment)),
            "Did not expect a problem containing [{$fragment}].",
        );
    }

    // ------------------------------------------------------------- currency

    /**
     * The ceiling has to mean the same amount on every account.
     *
     * It was one bare number compared against a budget in whatever the account
     * bills in, so 20 stopped a 20 dollar campaign and a 20 rupee one alike.
     */
    public function test_a_ceiling_in_another_currency_is_used_when_configured(): void
    {
        $under = $this->campaign(['currency' => 'INR', 'budget' => 1500]);
        $this->assertAllows($under, 'ceiling');

        $over = $this->campaign(['currency' => 'INR', 'budget' => 1800]);
        $this->assertBlocks($over, 'above the');
    }

    /** A currency with no configured ceiling fails closed rather than borrowing one. */
    public function test_an_unconfigured_currency_cannot_be_published_to(): void
    {
        $this->assertBlocks(
            $this->campaign(['currency' => 'JPY', 'budget' => 2000]),
            'no budget ceiling is configured for JPY',
        );
    }

    /** The old behaviour, unchanged, for the currency the ceiling is written in. */
    public function test_the_base_currency_still_uses_the_plain_ceiling(): void
    {
        $this->assertBlocks($this->campaign(['budget' => 50]), 'above the');
        $this->assertAllows($this->campaign(['budget' => 10]), 'above the');
    }

    /**
     * The tool has to enforce the same ceiling as the gate.
     *
     * They read two different numbers: the gate was currency aware and the tool
     * was not, so on a rupee account the tool refused 800 against a limit
     * written in dollars while the gate would have allowed it. A buyer could
     * not set any usable budget at all.
     */
    public function test_the_budget_tool_uses_the_same_ceiling_as_the_gate(): void
    {
        $campaign = $this->campaign(['currency' => 'INR', 'budget' => null]);

        $workspace = app(Workspace::class);
        $workspace->ownedBy(auth()->user());
        $workspace->produced($campaign);
        $workspace->focusOn($campaign);

        $response = json_decode(
            app(SetCampaignBudget::class)->handle(new ToolRequest(['amount' => 800])),
            true,
        );

        $this->assertTrue($response['ok'], 'the tool accepts an amount inside the INR ceiling');
        $this->assertSame('800.00', $response['amount']);
        $this->assertAllows($campaign->fresh(), 'above the');
    }

    /** And still refuses one over it, in that account's own currency. */
    public function test_the_budget_tool_refuses_over_the_currency_ceiling(): void
    {
        $campaign = $this->campaign(['currency' => 'INR', 'budget' => null]);

        $workspace = app(Workspace::class);
        $workspace->ownedBy(auth()->user());
        $workspace->produced($campaign);
        $workspace->focusOn($campaign);

        $response = json_decode(
            app(SetCampaignBudget::class)->handle(new ToolRequest(['amount' => 1800])),
            true,
        );

        $this->assertFalse($response['ok']);
        $this->assertStringContainsString('1700.00 ceiling', $response['error']);
    }

    /** An unconfigured currency is an administrator's problem, not a smaller number. */
    public function test_the_budget_tool_refuses_an_unconfigured_currency(): void
    {
        $campaign = $this->campaign(['currency' => 'JPY', 'budget' => null]);

        $workspace = app(Workspace::class);
        $workspace->ownedBy(auth()->user());
        $workspace->produced($campaign);
        $workspace->focusOn($campaign);

        $response = json_decode(
            app(SetCampaignBudget::class)->handle(new ToolRequest(['amount' => 2000])),
            true,
        );

        $this->assertFalse($response['ok']);
        $this->assertStringContainsString('no budget ceiling is configured for JPY', lcfirst($response['error']));
    }

    // ------------------------------------------------------------- ad copy

    /** Meta shortens an over-long headline and runs it, so nothing ever fails. */
    public function test_a_headline_over_the_platform_limit_is_refused(): void
    {
        $campaign = $this->campaign();
        $campaign->ads()->create([
            'headline' => str_repeat('a', 41),
            'primary_text' => 'Half price until Sunday.',
            'cta' => 'SHOP_NOW',
        ]);

        $this->assertBlocks($campaign, 'headline is 41 characters, over Meta\'s 40 limit');
    }

    public function test_a_description_over_the_platform_limit_is_refused(): void
    {
        $campaign = $this->campaign();
        $campaign->ads()->create([
            'headline' => 'Winter Sale',
            'primary_text' => 'Half price.',
            'description' => str_repeat('b', 126),
            'cta' => 'SHOP_NOW',
        ]);

        $this->assertBlocks($campaign, 'description is 126 characters');
    }

    public function test_copy_inside_the_limits_passes(): void
    {
        $campaign = $this->campaign();
        $campaign->ads()->create([
            'headline' => 'Winter Sale',
            'primary_text' => 'Half price until Sunday.',
            'description' => 'Everything must go.',
            'cta' => 'SHOP_NOW',
        ]);

        $this->assertAllows($campaign, 'over Meta');
    }

    // ---------------------------------------------------------- placements

    /**
     * Not every placement works for every objective, and the pairing is refused
     * at the ad set, after the campaign already exists.
     */
    public function test_a_placement_the_objective_cannot_use_is_refused(): void
    {
        $this->assertBlocks(
            $this->campaign([
                'objective' => 'OUTCOME_ENGAGEMENT',
                'placements' => ['facebook', 'audience_network'],
            ]),
            'cannot run on audience_network',
        );
    }

    public function test_a_placement_the_objective_supports_passes(): void
    {
        $this->assertAllows(
            $this->campaign(['objective' => 'OUTCOME_ENGAGEMENT', 'placements' => ['facebook', 'instagram']]),
            'cannot run on',
        );
    }

    // ----------------------------------------------------------- Instagram

    /**
     * An Instagram placement with no Instagram account runs those ads under the
     * Facebook Page's name and picture. Meta permits it, so nothing fails; it
     * simply does not look like the brand and nobody is told.
     */
    public function test_an_instagram_placement_without_an_account_is_refused(): void
    {
        $this->assertBlocks(
            $this->campaign(['placements' => ['facebook', 'instagram']]),
            'no Instagram account has been picked',
        );
    }

    /** Asked and answered "there is none" is an answer, and passes. */
    public function test_declining_an_instagram_account_passes(): void
    {
        $this->assertAllows(
            $this->campaign(['placements' => ['instagram'], 'instagram_account_id' => '']),
            'no Instagram account has been picked',
        );
    }

    public function test_a_facebook_only_campaign_is_never_asked(): void
    {
        $this->assertAllows(
            $this->campaign(['placements' => ['facebook']]),
            'Instagram',
        );
    }

    // ------------------------------------------------------------ tracking

    /** A malformed query string breaks every click on the ad. */
    public function test_malformed_click_tracking_is_refused(): void
    {
        $this->assertBlocks(
            $this->campaign(['utm' => 'utm_source=facebook&&broken']),
            'not a valid query string',
        );
    }

    public function test_valid_tracking_passes_including_metas_macros(): void
    {
        $this->assertAllows(
            $this->campaign(['utm' => 'utm_source=facebook&utm_campaign={{campaign.name}}']),
            'not a valid query string',
        );
    }

    /** Empty is the answer "no tracking", which is allowed. */
    public function test_no_tracking_is_not_a_problem(): void
    {
        $this->assertAllows($this->campaign(['utm' => '']), 'not a valid query string');
    }

    // --------------------------------------------------- optimisation goal

    /**
     * Sending a goal that does not match the objective comes back from Meta as
     * "conversion event unavailable", subcode 2446814, which is not a message
     * anyone can reason about.
     */
    public function test_an_optimisation_goal_that_contradicts_the_objective_is_refused(): void
    {
        $this->assertBlocks(
            $this->campaign(['objective' => 'OUTCOME_TRAFFIC', 'optimization_goal' => 'OFFSITE_CONVERSIONS']),
            'does not match the OUTCOME_TRAFFIC objective',
        );
    }

    public function test_the_goal_the_objective_implies_passes(): void
    {
        $this->assertAllows(
            $this->campaign(['objective' => 'OUTCOME_TRAFFIC', 'optimization_goal' => 'LINK_CLICKS']),
            'does not match',
        );
    }

    /**
     * A goal with nothing to disagree with is not a mismatch.
     *
     * An adopted campaign can carry an optimisation goal read off its ad set
     * while Meta returned no objective. Reported as a mismatch it read "does
     * not match the  objective", naming nothing, next to the separate and
     * accurate "objective not set".
     */
    public function test_an_optimisation_goal_without_an_objective_is_not_reported_as_a_mismatch(): void
    {
        $blocking = app(PublishGate::class)->check(
            $this->campaign(['objective' => null, 'optimization_goal' => 'LINK_CLICKS']),
        );

        $this->assertNotEmpty($blocking, 'a campaign with no objective is still blocked');

        foreach ($blocking as $reason) {
            $this->assertStringNotContainsString('does not match', $reason);
        }

        $this->assertContains('objective not set', $blocking);
    }
}
