<?php

namespace Tests\Feature;

use App\Campaigns\Publisher;
use App\Campaigns\PublishGate;
use App\Models\Asset;
use App\Models\AssetPlatformRef;
use App\Models\Campaign;
use App\Models\User;
use App\Services\Platforms\Support\Guardrails;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

/**
 * Four guards that were doing their job with nothing checking they still did.
 *
 * Each was found by replacing its body with a no-op and watching the whole suite
 * stay green: PublishGate::schedule(), PublishGate::unknownCategories(),
 * Guardrails::scrub() and the unimplemented-platform lock in Publisher::approve().
 * Three of the four sit between a campaign and a live ad account, and the fourth
 * is what keeps access tokens out of the audit table.
 */
class GuardsNothingWasCheckingTest extends TestCase
{
    use RefreshDatabase;

    private function campaign(array $attributes = []): Campaign
    {
        return Campaign::create([
            'user_id' => User::factory()->create()->id,
            'name' => 'Winter Sale',
            'platform' => 'meta',
            'status' => 'draft',
            'objective' => 'OUTCOME_TRAFFIC',
            'ad_account_id' => 'act_111',
            'page_id' => 'page-1',
            'landing_url' => 'https://example.com',
            'budget' => 10,
            'budget_mode' => 'lifetime',
            'currency' => 'USD',
            'locations' => ['countries' => ['US']],
            ...$attributes,
        ]);
    }

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

    private function mentioning(Campaign $campaign, string $needle): array
    {
        return array_values(array_filter(
            $this->problems($campaign),
            fn (string $problem): bool => str_contains(strtolower($problem), strtolower($needle)),
        ));
    }

    // ------------------------------------------------------------- schedule

    /**
     * An end date before the start is refused.
     *
     * Only LinkedIn duplicated this rule in its own additionalProblems(), and
     * only that copy was tested, so for Meta and Google the shared gate was
     * unguarded: replacing schedule() with `return []` left the suite green.
     */
    public function test_an_end_date_before_the_start_is_refused(): void
    {
        $campaign = $this->campaign([
            'starts_at' => now()->addDays(10),
            'ends_at' => now()->addDays(3),
        ]);

        $this->assertNotEmpty(
            $this->mentioning($campaign, 'end'),
            'a campaign ending before it starts was not refused',
        );
    }

    /** And an end date already past is refused too. */
    public function test_an_end_date_in_the_past_is_refused(): void
    {
        $campaign = $this->campaign([
            'starts_at' => now()->subDays(10),
            'ends_at' => now()->subDays(3),
        ]);

        $this->assertNotEmpty(
            $this->mentioning($campaign, 'past'),
            'a campaign that already ended was not refused',
        );
    }

    /** A sane schedule raises nothing about dates. */
    public function test_a_workable_schedule_raises_no_date_problem(): void
    {
        $campaign = $this->campaign([
            'starts_at' => now()->addDay(),
            'ends_at' => now()->addDays(14),
        ]);

        $this->assertSame([], $this->mentioning($campaign, 'end date'));
    }

    // --------------------------------------------------- special categories

    /**
     * A category the platform does not accept is refused before publishing.
     *
     * Meta rejects an unknown special ad category outright, and by the time it
     * says so the campaign exists. Replacing unknownCategories() with
     * `return []` left the suite green.
     */
    public function test_a_category_the_platform_does_not_know_is_refused(): void
    {
        $campaign = $this->campaign(['special_ad_categories' => ['ASTROLOGY']]);

        $this->assertNotEmpty(
            $this->mentioning($campaign, 'astrology'),
            'an unknown special ad category reached the publish gate unchallenged',
        );
    }

    /** A real one is accepted. */
    public function test_a_declared_category_the_platform_knows_is_accepted(): void
    {
        $campaign = $this->campaign(['special_ad_categories' => ['HOUSING']]);

        $this->assertSame([], $this->mentioning($campaign, 'not a special ad category'));
    }

    // -------------------------------------------------------- token redaction

    /**
     * The access token never reaches the audit table.
     *
     * Every Graph call carries one in its query, and api_calls.request keeps
     * the payload for diagnosis. Commenting out the unset left the suite green,
     * and the token would have been written to a row, a backup and anything
     * reading either.
     */
    public function test_the_access_token_is_stripped_from_an_audited_payload(): void
    {
        $scrubbed = app(Guardrails::class)->scrub([
            'access_token' => 'EAAB-a-real-looking-token',
            'name' => 'Winter Sale',
        ]);

        $this->assertArrayNotHasKey('access_token', $scrubbed);
        $this->assertSame('Winter Sale', $scrubbed['name']);
        $this->assertStringNotContainsString(
            'EAAB-a-real-looking-token',
            (string) json_encode($scrubbed),
        );
    }

    /** And a long value is cut rather than stored whole. */
    public function test_a_long_value_is_truncated_with_its_length_noted(): void
    {
        $scrubbed = app(Guardrails::class)->scrub(['body' => str_repeat('x', 900)]);

        $this->assertLessThan(900, strlen($scrubbed['body']));
        $this->assertStringContainsString('900 chars', $scrubbed['body']);
    }

    // ------------------------------------------------- the platform lock

    /**
     * A platform with no publisher cannot be approved.
     *
     * The docblock calls this "the second lock, after the gate". Replacing the
     * condition with `if (false && ...)` left the suite green, and no test
     * anywhere mentions the message.
     */
    /**
     * A campaign that passes the gate is still refused with no publisher.
     *
     * The lock cannot be reached through an unimplemented platform, because
     * UnsupportedPlatformRules makes the gate say "TikTok is not connected yet"
     * first. That is the masking its own docblock anticipates: it calls itself
     * "the second lock, after the gate", and the gate is "a list of reasons
     * that a rules class can stop supplying". So the case it guards is a
     * campaign the gate passes and no publisher exists for, which is what this
     * builds by emptying the publisher map for Meta.
     */
    public function test_approving_a_campaign_with_no_publisher_is_refused(): void
    {
        $campaign = $this->campaign([
            'budget_mode' => 'daily',
            'placements' => ['facebook'],
            'pixel_id' => null,
            'special_ad_categories' => ['NONE'],
            'utm' => '',
        ]);

        $asset = Asset::create([
            'owner_type' => (new User)->getMorphClass(),
            'owner_id' => $campaign->user_id,
            'kind' => 'image', 'source' => 'generated', 'disk' => 'public',
            'path' => 'creatives/x.png', 'original_name' => 'x.png',
            'mime' => 'image/png', 'bytes' => 10, 'checksum' => 'sum',
        ]);

        AssetPlatformRef::create([
            'asset_id' => $asset->id, 'platform' => 'meta',
            'ad_account_id' => 'act_111', 'external_hash' => 'HASH123',
        ]);

        $campaign->ads()->create([
            'headline' => 'Winter Sale',
            'primary_text' => 'Everything reduced.',
            'cta' => 'SHOP_NOW',
            'asset_id' => $asset->id,
        ]);

        $decision = app(Publisher::class)->propose($campaign->fresh(), 'tester');

        // Removed after the proposal, so the gate has already had its say and
        // what is left standing between this campaign and the API is the lock.
        config(['platforms.publishers' => []]);

        $applied = app(Publisher::class)->approve($decision, 'tester');

        $this->assertFalse($applied->wasApplied());
        $this->assertStringContainsString(
            'publishing is not implemented for [meta] yet',
            (string) $applied->reason,
            'the gate passed and nothing else stopped it reaching a platform with no publisher',
        );
    }

    /**
     * A campaign that is already published is refused a second publish.
     *
     * Two routes reach approve() - the chat tool and the panel button - and
     * publishing from one leaves the other's page showing a stale, still-enabled
     * button. A second run created a whole second campaign on the account while
     * external_ids kept only the last one, orphaning the first.
     *
     * The guard was removable with the entire suite green, and once a gate-clean
     * fixture existed it was still removable - because PublishGate:67 reports
     * "already published as ..." as well, and the gate is re-checked immediately
     * below. So this guard is a duplicate: deleting it changes the reason from
     * "already published as X" to "no longer ready: already published as X" and
     * nothing else. That is the masking this file is named for, in its most literal
     * form - two guards saying the same sentence.
     *
     * Kept, because it creates real campaigns on real accounts and the cost of the
     * duplicate is one comparison. Pinned on the exact wording so that removing it
     * is a decision rather than an accident, and so the gate wording and this one
     * cannot drift apart unnoticed.
     */
    public function test_a_campaign_that_is_already_published_is_not_published_again(): void
    {
        $campaign = $this->readyCampaign();

        $decision = app(Publisher::class)->propose($campaign->fresh(), 'tester');

        // Published by the other route between the proposal and the approval,
        // which is exactly the race the guard is for.
        $campaign->update(['external_campaign_id' => '120252047635070626']);

        Http::fake(['*' => Http::response(['id' => 'SHOULD-NOT-BE-CALLED'])]);

        $applied = app(Publisher::class)->approve($decision, 'tester');

        $this->assertFalse($applied->wasApplied());

        // The guard's own wording, not the gate's rendering of the same fact.
        $this->assertSame('already published as 120252047635070626', (string) $applied->reason);

        Http::assertNothingSent();
        $this->assertSame('120252047635070626', $campaign->fresh()->external_campaign_id);
    }

    /**
     * The gate is asked again at approval, not only at proposal.
     *
     * A campaign can change between the two - another turn, another tab, a tool
     * clearing a field - and approving a summary that was true a minute ago must
     * not publish something else. Also removable with the suite green: the tests
     * that approve anything arrange a campaign that stays ready, so nothing noticed
     * when the second check went away.
     */
    public function test_a_campaign_that_stopped_being_ready_is_refused_at_approval(): void
    {
        $campaign = $this->readyCampaign();

        $decision = app(Publisher::class)->propose($campaign->fresh(), 'tester');

        // Broken after the proposal. The gate passed when the summary was shown
        // and would refuse now, which is the whole point of asking twice.
        $campaign->update(['landing_url' => null]);

        Http::fake(['*' => Http::response(['id' => 'SHOULD-NOT-BE-CALLED'])]);

        $applied = app(Publisher::class)->approve($decision, 'tester');

        $this->assertFalse($applied->wasApplied());
        $this->assertStringContainsString('no longer ready', (string) $applied->reason);
        $this->assertStringContainsString('landing page URL not set', (string) $applied->reason);

        Http::assertNothingSent();
    }

    /**
     * A campaign the gate has nothing to say about.
     *
     * Shared by the two above, because both need the guard under test to be the
     * only thing standing in the way - a campaign the gate would stop is a campaign
     * that proves nothing about either.
     */
    private function readyCampaign(): Campaign
    {
        $campaign = $this->campaign([
            'budget_mode' => 'daily',
            'placements' => ['facebook'],
            'pixel_id' => null,
            'special_ad_categories' => ['NONE'],
            'utm' => '',
        ]);

        $asset = Asset::create([
            'owner_type' => (new User)->getMorphClass(),
            'owner_id' => $campaign->user_id,
            'kind' => 'image', 'source' => 'generated', 'disk' => 'public',
            'path' => 'creatives/ready.png', 'original_name' => 'ready.png',
            'mime' => 'image/png', 'bytes' => 10, 'checksum' => 'ready',
        ]);

        AssetPlatformRef::create([
            'asset_id' => $asset->id, 'platform' => 'meta',
            'ad_account_id' => 'act_111', 'external_hash' => 'HASH123',
        ]);

        $campaign->ads()->create([
            'headline' => 'Winter Sale',
            'primary_text' => 'Everything reduced.',
            'cta' => 'SHOP_NOW',
            'asset_id' => $asset->id,
        ]);

        $this->assertSame(
            [],
            app(PublishGate::class)->check($campaign->fresh()),
            'the fixture is not gate-clean, so an earlier guard would mask the one under test',
        );

        return $campaign->fresh();
    }
}
