<?php

namespace Tests\Unit\AdOps;

use App\Services\AdOps\CreativeCsv;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class CreativeCsvTest extends TestCase
{
    public function testEmptyCampaignsColumnIsOmittedNotBlankElement(): void
    {
        // Regression: explode(',', '') produced [''], sending
        // {"campaigns":[""]} and overwriting existing campaign associations.
        $payload = CreativeCsv::decodeRow(['name' => 'X', 'campaigns' => '']);

        $this->assertArrayNotHasKey('campaigns', $payload);
    }

    public function testCampaignsColumnTrimsAndFilters(): void
    {
        $payload = CreativeCsv::decodeRow(['name' => 'X', 'campaigns' => '1, 2 ,,  ,3']);

        $this->assertSame(['1', '2', '3'], $payload['campaigns']);
    }

    public function testRoundTripPreservesCoreFields(): void
    {
        $original = [
            'id'          => 42,
            'name'        => 'banner-A',
            'type'        => 'display',
            'status'      => 'A',
            'campaigns'   => ['10', '20'],
            'destination' => 'https://example.com',
            'size'        => '300x250',
        ];

        $encoded = CreativeCsv::encodeRow((object) $original);
        $decoded = CreativeCsv::decodeRow($encoded);

        $this->assertSame('banner-A', $decoded['name']);
        $this->assertSame('display', $decoded['type']);
        $this->assertSame(['10', '20'], $decoded['campaigns']);
    }

    public function testDestinationStripsWhitespaceAndNonPrintable(): void
    {
        $payload = CreativeCsv::decodeRow([
            'destination' => " https://example.com/path\x00\x07?q=1 ",
        ]);

        $this->assertSame('https://example.com/path?q=1', $payload['destination']);
    }

    // ─────────────────────────────── display_url validation

    /**
     * Values that must pass. Every one is a real stored display URL, and the
     * scheme-less ones are 82.5% of the column — a check that demanded a scheme
     * would reject them all.
     *
     * @return array<int, array{0: string}>
     */
    public static function usableDisplayUrls(): array
    {
        return [
            ['Adzuna.com.au'],
            ['items.com'],
            ['www.CanDoFinance.com'],
            ['www.bhg.com/shop'],
            ['Adzuna.com.au/[KEYWORD]'],
            ['{KEYWORD:www.shopamaz.us}'],
            ['Bathroom.services'],
            ['https://www.example.com'],
            ['http://example.com/path?a=1'],
            // A typo'd scheme still carries a real domain; rejecting these would
            // break 107 live creatives for one advertiser alone.
            ['htttps://www.3dayblinds.com'],
            // A macro in the middle: the character before ".com" is "}".
            ['http://www.{Keyword:bankweb}.com'],
            // A bare IPv4 host, on 39 live creatives.
            ['37.188.125.84'],
            // Unicode domains, accepted wherever the accent falls.
            ['café.com'],
            ['münchen.de'],
        ];
    }

    /** @return array<int, array{0: string}> */
    public static function unusableDisplayUrls(): array
    {
        return [
            ['N/A'],
            ['http://'],
            ['string'],
            ['test'],
            ['hindsightharry/SportsBet'],
            ['lifestyle-hacker'],
            ['d'],
        ];
    }

    #[DataProvider('usableDisplayUrls')]
    public function testARealDisplayUrlIsAccepted(string $value): void
    {
        $this->assertSame([], CreativeCsv::rowErrors(['type' => 'text', 'display_url' => $value]));
    }

    #[DataProvider('unusableDisplayUrls')]
    public function testTextThatIsNotADisplayUrlStopsANewCreative(string $value): void
    {
        $errors = CreativeCsv::rowErrors(['type' => 'text', 'display_url' => $value]);

        $this->assertCount(1, $errors);
        $this->assertStringContainsString('is not a usable display URL', $errors[0]);
        $this->assertStringContainsString($value, $errors[0]);
    }

    #[DataProvider('unusableDisplayUrls')]
    public function testAnUpdateKeepsTheStoredDisplayUrlInsteadOfFailingTheRow(string $value): void
    {
        // 281 active creatives across 62 advertisers store a display URL that is not
        // one. Refusing update rows would mean exporting, changing one unrelated
        // column and re-uploading loses the edit over a legacy value.
        $row = ['id' => '55', 'type' => 'text', 'display_url' => $value];

        $this->assertSame([], CreativeCsv::rowErrors($row));

        $payload = CreativeCsv::decodeRow($row, $ignored);
        $this->assertArrayNotHasKey('display_url', $payload, 'the junk must not be written');
        $this->assertStringContainsString('The stored display URL was kept', $this->ignoredFor($row)['display_url']);
    }

    public function testAnUpdateStillSendsAUsableDisplayUrl(): void
    {
        $payload = CreativeCsv::decodeRow(['id' => '55', 'type' => 'text', 'display_url' => 'www.example.com/shop'], $ignored);

        $this->assertSame('www.example.com/shop', $payload['display_url']);
        $this->assertSame([], $ignored);
    }

    public function testControlCharactersAreStrippedFromADisplayUrl(): void
    {
        // A quoted CSV cell can hold a newline or a NUL, which used to reach the
        // column verbatim — `destination` has always been cleaned, this was not.
        $this->assertSame(
            'www.example.com',
            CreativeCsv::decodeRow(['type' => 'text', 'display_url' => "www.example\x00.com\n"], $i)['display_url']
        );

        // The check stays unanchored, so a value that still holds a domain after
        // cleaning is accepted — that is what lets macros and prefixes through. What
        // matters is that the control characters never reach the column.
        $this->assertSame([], CreativeCsv::rowErrors(['type' => 'text', 'display_url' => "junk\nwww.example.com"]));
        $this->assertCount(1, CreativeCsv::rowErrors(['type' => 'text', 'display_url' => "N/A\nnope"]));
    }

    public function testABlankDisplayUrlIsLeftToTheApiToRefuse(): void
    {
        // The API's own `required` rule answers for it, and names the field.
        $this->assertSame([], CreativeCsv::rowErrors(['type' => 'text', 'display_url' => '']));
        $this->assertSame([], CreativeCsv::rowErrors([]));
    }

    public function testATypeThatHasNoDisplayUrlIsNotRefusedOverOne(): void
    {
        // The API filters display_url out for an image creative, so refusing the row
        // over it would block a row that imports fine — and the same commit reports
        // the cell as dropped.
        $row = [
            'type'        => 'image',
            'name'        => 'QA',
            'destination' => 'http://example.com/',
            'image_url'   => 'http://example.com/i.png',
            'size'        => '300x250',
            'display_url' => 'Free Car Insurance',
        ];

        $this->assertSame([], CreativeCsv::rowErrors($row));
        $this->assertStringContainsString('type "image" has no display_url', $this->ignoredFor($row)['display_url']);
    }

    public function testADisplayUrlOverFiftyCharactersStopsTheRow(): void
    {
        $errors = CreativeCsv::rowErrors(['type' => 'text', 'display_url' => 'www.' . str_repeat('a', 50) . '.com']);

        $this->assertStringContainsString('over the 50-character limit', $errors[0]);
    }

    public function testAnAmpersandDisplayUrlUnderFiftyCharactersIsAccepted(): void
    {
        // The API does NOT encode what it stores — CI3's prep rules mutate the
        // validator's own copy, while the controller inserts its $data untouched.
        // 0 of 226,074 stored display URLs hold an "&amp;" entity and 159 hold a raw
        // "&", so measuring the escaped form refused values that store intact.
        $value = "http://www.yidio.com/show/grey's-anatomy/season-8/";
        $this->assertLessThanOrEqual(50, mb_strlen($value));

        $this->assertSame([], CreativeCsv::rowErrors(['type' => 'text', 'display_url' => $value]));
        $this->assertSame([], CreativeCsv::rowErrors([
            'type'        => 'text',
            'display_url' => 'http://peerfly.com/x/0/3443/82103/<<subid>>/',
        ]));
    }

    // ─────────────────────────────── parent_creative_id and per-type cells

    /** @return array<string, string> reason keyed by column */
    private function ignoredFor(array $row): array
    {
        $ignored = null;
        CreativeCsv::decodeRow($row, $ignored);

        $out = [];
        foreach ($ignored as $entry) {
            $out[$entry['column']] = $entry['reason'];
        }

        return $out;
    }

    public function testATextValueInParentCreativeIdStopsANewCreativeThatWouldUseIt(): void
    {
        $errors = CreativeCsv::rowErrors(['type' => 'text', 'parent_creative_id' => 'adschedule1']);

        $this->assertCount(1, $errors);
        $this->assertStringContainsString('"adschedule1" is not a valid parent creative id', $errors[0]);
    }

    public function testANumericParentCreativeIdIsAccepted(): void
    {
        $this->assertSame([], CreativeCsv::rowErrors(['type' => 'text', 'parent_creative_id' => '991']));
        // 0 is how the API stores "no parent"; it must not be refused.
        $this->assertSame([], CreativeCsv::rowErrors(['type' => 'text', 'parent_creative_id' => '0']));
    }

    public function testAnUnrecognisedTypeIsLeftEntirelyToTheApi(): void
    {
        // "Text" is not a type the API knows, and that — not the parent id — is what
        // stops the row. Reporting anything else here would hide it.
        $this->assertSame([], CreativeCsv::rowErrors(['type' => 'Text', 'parent_creative_id' => 'adschedule1']));
        $this->assertSame(['type'], CreativeCsv::requiredForCreate(['type' => 'Text']));
    }

    public function testAnUpdateKeepsTheStoredParentInsteadOfFailingTheRow(): void
    {
        $row = ['id' => '55', 'type' => 'text', 'parent_creative_id' => 'adschedule1'];

        $this->assertSame([], CreativeCsv::rowErrors($row));
        $payload = CreativeCsv::decodeRow($row, $ignored);
        $this->assertArrayNotHasKey('parent_creative_id', $payload);
        $this->assertStringContainsString('The stored parent creative was kept', $this->ignoredFor($row)['parent_creative_id']);
    }

    public function testAnUpdateReportsNoPerTypeCells(): void
    {
        // index_patch overwrites the type with the stored one and filters against
        // THAT, so a warning keyed off the CSV's type cell would lie both ways.
        $this->assertSame([], $this->ignoredFor(['id' => '55', 'type' => 'image', 'name' => 'QA', 'title' => 'My new title']));
    }

    public function testATypeThatHasNoParentCreativeIdReportsTheCellInsteadOfRefusingTheRow(): void
    {
        // The API drops the cell for an image creative, so the honest answer is
        // "this column does not apply here", not "that is not a valid id".
        $row = ['type' => 'image', 'name' => 'QA', 'parent_creative_id' => 'adschedule1'];

        $this->assertSame([], CreativeCsv::rowErrors($row));
        $this->assertStringContainsString(
            'type "image" has no parent_creative_id',
            $this->ignoredFor($row)['parent_creative_id']
        );
    }

    public function testCellsTheTypeDropsAreReported(): void
    {
        $script = $this->ignoredFor(['type' => 'script', 'name' => 'QA', 'destination' => 'http://x.test/', 'title' => 'T']);
        $this->assertStringContainsString('only image, text, native, instl creatives use it', $script['destination']);
        $this->assertStringContainsString('only text, native creatives use it', $script['title']);

        // native derives its size from the resized image, so a size cell is inert.
        $native = $this->ignoredFor(['type' => 'native', 'name' => 'QA', 'size' => '300x250']);
        $this->assertStringContainsString('type "native" has no size', $native['size']);
    }

    public function testAColumnTheTypeKeepsIsNotReported(): void
    {
        $this->assertSame([], $this->ignoredFor([
            'type'        => 'text',
            'name'        => 'QA',
            'destination' => 'http://x.test/',
            'title'       => 'T',
            'description' => 'D',
            'display_url' => 'www.example.com',
        ]));
    }

    public function testABlankCellForAnotherTypeIsNotReported(): void
    {
        // Every export carries every column, so blanks are the normal case.
        $this->assertSame([], $this->ignoredFor([
            'type'               => 'image',
            'name'               => 'QA',
            'parent_creative_id' => '',
            'rank'               => '',
            'content'            => '',
        ]));
    }

    public function testAnUnrecognisedTypeReportsNoPerTypeCells(): void
    {
        // "wrong creative type" is the API's answer; do not pile guesses on top.
        $this->assertSame([], $this->ignoredFor(['type' => 'Image', 'name' => 'QA', 'content' => 'x']));
    }

    // ─────────────────────────────── rank

    public function testARankAboveTheApiRangeStopsANewCreative(): void
    {
        // Admin.creatives.rank is tinyint, so anything over 127 was clamped on the
        // way in — 998 stored as 127, on a save reported as a success. The column
        // is widened separately; this refuses what is wrong at any width.
        $errors = CreativeCsv::rowErrors([
            'type' => 'text', 'name' => 'QA', 'parent_creative_id' => '250', 'rank' => '1000',
        ]);

        $this->assertNotSame([], $errors);
        $this->assertStringContainsString('1 to 999', $errors[0]);
    }

    public function testARankOfZeroStopsANewCreative(): void
    {
        $errors = CreativeCsv::rowErrors([
            'type' => 'text', 'name' => 'QA', 'parent_creative_id' => '250', 'rank' => '0',
        ]);

        $this->assertNotSame([], $errors);
    }

    public function testATextRankIsRefusedByName(): void
    {
        $errors = CreativeCsv::rowErrors([
            'type' => 'text', 'name' => 'QA', 'parent_creative_id' => '250', 'rank' => 'first',
        ]);

        $this->assertStringContainsString('"first"', $errors[0]);
    }

    public function testARankInsideTheRangeIsAccepted(): void
    {
        $this->assertSame([], CreativeCsv::rowErrors([
            'type' => 'text', 'name' => 'QA', 'parent_creative_id' => '250', 'rank' => '998',
        ]));
    }

    public function testABadRankOnAnUpdateIsSetAsideRatherThanWritten(): void
    {
        $ignored = null;
        $payload = CreativeCsv::decodeRow(['id' => '55', 'rank' => '5000'], $ignored);

        $this->assertArrayNotHasKey('rank', $payload);
        $reasons = array_column($ignored, 'reason', 'column');
        $this->assertStringContainsString('The stored rank was kept', $reasons['rank']);
    }

    public function testARepeatedCampaignIdIsSentOnlyOnce(): void
    {
        // campaign_creative_link's primary key is (campaign_id, creative_id) and
        // linkCampaigns builds one multi-row insert, so the repeat made the whole
        // statement fail with MySQL 1062 and the API answer HTTP 500.
        $payload = CreativeCsv::decodeRow(['id' => '7', 'campaigns' => '4413,4413,4414']);

        $this->assertSame(['4413', '4414'], $payload['campaigns']);
    }
}
