<?php

namespace Tests\Unit\AdOps;

use App\Services\AdOps\ApiClient;
use App\Services\AdOps\CampaignCsv;
use App\Services\AdOps\CreativeCsv;
use App\Services\AdOps\EventLogService;
use App\Services\AdOps\ImportExportService;
use PHPUnit\Framework\TestCase;

/**
 * Integration-style cover for ImportExportService::importFromCsv, the
 * orchestration the unit tests never touched: create-vs-update routing, the
 * required-column pre-check, and the row messages the user actually reads.
 *
 * The important thing pinned here is that the pre-check applies to CREATE rows
 * only. Ad ops routinely export, edit one column and re-upload, so a check that
 * also fired on updates would reject the normal workflow — and nothing else in
 * the suite would have noticed.
 */
final class ImportExportServiceImportTest extends TestCase
{
    private function service(RecordingApiClient $api): ImportExportService
    {
        return new ImportExportService($api, new SilentEventLog());
    }

    private function csv(string $body): string
    {
        $path = tempnam(sys_get_temp_dir(), 'ie-import-') . '.csv';
        file_put_contents($path, $body);

        return $path;
    }

    public function testUpdateRowWithBlankRequiredColumnsSendsNoneOfThem(): void
    {
        // The regression guard: name/budget/bid are Optional on update, and the API
        // only validates the fields actually sent. This test used to encode the bug —
        // the stub discards the payload and always answers 200, so a blank name
        // "passed" here while the real API refused it with "The Campaign Name field
        // is required". Assert on the payload, not just the call.
        $api = new PayloadRecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type,budget_type\n77,,,,,\n")
        );

        // budget_type is not a column the decoder reads, so the payload is empty and
        // the row is reported rather than sent — see the dedicated message below.
        $this->assertSame([], $api->calls);

        $api = new PayloadRecordingApiClient();
        $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type,targeting_ron\n77,,,,,1\n")
        );

        $this->assertSame([['patch', 'campaigns/77']], $api->calls);
        foreach (['name', 'budget', 'bid', 'type'] as $blank) {
            $this->assertArrayNotHasKey($blank, $api->payloads[0], $blank . ' must not be sent blank');
        }
    }

    public function testAnUpdateRowWithNothingButAnIdSaysThereIsNothingToDo(): void
    {
        // Now that a blank cell means "leave it alone", an id with every other cell
        // blank is not a format fault — there is nothing to do. It used to PATCH
        // name='' and come back with "The Campaign Name field is required".
        $api = new PayloadRecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type\n77,,,,\n")
        );

        $this->assertSame([], $api->calls);
        $this->assertSame(
            'Row 2: nothing to update on campaign 77 — every cell except the id is blank.',
            $res[0]['message']
        );
    }

    public function testABlankDateOnAnUpdateLeavesTheStoredDateAlone(): void
    {
        // Reversed on review. A blank date does clear one — Campaign_model turns ''
        // into NULL — but nothing distinguishes "clear it" from "I filled in the id
        // and the one column I wanted", and the template carries both date columns.
        // Sending them NULLed the dates of any campaign updated from a template row,
        // silently, on a row reported green: 629 active campaigns hold a start date.
        $api = new PayloadRecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,budget,start_date,end_date\n77,50,,\n")
        );

        $this->assertSame([['patch', 'campaigns/77']], $api->calls);
        $this->assertArrayNotHasKey('start_date', $api->payloads[0]);
        $this->assertArrayNotHasKey('end_date', $api->payloads[0]);
        $this->assertTrue($res[0]['status']);
    }

    public function testAFilledDateOnAnUpdateIsStillSent(): void
    {
        $api = new PayloadRecordingApiClient();
        $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,start_date\n77,2026-09-01\n")
        );

        $this->assertSame('2026-09-01', $api->payloads[0]['start_date']);
    }

    public function testABlankCellOnACreateIsStillRefusedLocally(): void
    {
        // Create semantics are unchanged: the pre-check reads the raw CSV row, so a
        // blank required cell is still named before any HTTP call.
        $api = new PayloadRecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type\n,,,,display\n")
        );

        $this->assertSame([], $api->calls);
        $this->assertStringContainsString('missing required columns: name, budget, bid', $res[0]['message']);
    }

    public function testABlankCreativeCellOnAnUpdateIsNotSent(): void
    {
        $api = new PayloadRecordingApiClient();
        $this->service($api)->importCreativesFromCsv(
            $this->csv("id,type,name,title,description,display_url,destination\n55,text,,,,,\n")
        );

        foreach (['name', 'title', 'description', 'display_url', 'destination'] as $blank) {
            $this->assertArrayNotHasKey($blank, $api->payloads[0], $blank . ' must not be sent blank');
        }
    }

    public function testABlankParentCreativeIdOnAnUpdateLeavesTheParentInPlace(): void
    {
        // Reversed on review. Blank IS how a child creative is unlinked — the model
        // zeroes rank alongside it — but the template carries both columns, so a row
        // with only the id and a name filled in detached the creative from its parent
        // on a row reported green, and the CSV cannot put it back.
        $api = new PayloadRecordingApiClient();
        $this->service($api)->importCreativesFromCsv(
            $this->csv("id,type,title,parent_creative_id,rank\n55,text,New title,,\n")
        );

        $this->assertSame([['patch', 'creatives/55']], $api->calls);
        $this->assertArrayNotHasKey('parent_creative_id', $api->payloads[0]);
        $this->assertArrayNotHasKey('rank', $api->payloads[0]);
    }

    public function testUpdateRowWithColumnsAbsentFromTheHeaderStillReachesTheApi(): void
    {
        $api = new RecordingApiClient();
        $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,budget\n77,50\n")
        );

        $this->assertSame([['patch', 'campaigns/77']], $api->calls);
    }

    public function testCreateRowNamesEveryMissingRequiredColumnAndSkipsTheApi(): void
    {
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type,targeting_countries\n,,,,,USA\n")
        );

        $this->assertSame([], $api->calls, 'A row that cannot be created must not be sent');
        $this->assertFalse($res[0]['status']);
        $this->assertSame(
            'Row 2: cannot create campaign — missing required columns: name, budget, bid, type.',
            $res[0]['message']
        );
    }

    public function testCreateRowUsesTheSingularForOneMissingColumn(): void
    {
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type\n,Summer,50,0.05,\n")
        );

        $this->assertSame([], $api->calls);
        $this->assertSame(
            'Row 2: cannot create campaign — missing required column: type.',
            $res[0]['message']
        );
    }

    public function testValidCreateRowReachesTheApi(): void
    {
        $api = new RecordingApiClient();
        $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type\n,Summer,50,0.05,display\n")
        );

        $this->assertSame([['post', 'campaigns']], $api->calls);
    }

    public function testValueRulesAreLeftToTheApi(): void
    {
        // budget below the minimum and a bid below the documented floor must still
        // be sent: the bid range varies per advertiser, so only the API can judge.
        $api = new RecordingApiClient();
        $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type\n,Summer,1,0.0001,display\n")
        );

        $this->assertSame([['post', 'campaigns']], $api->calls);
    }

    public function testAMangledNumericIdIsRefusedRatherThanDuplicated(): void
    {
        // "5.0" is what a spreadsheet does to an id column. It used to fall through
        // to create, silently duplicating the campaign.
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type\n5.0,Summer,50,0.05,display\n")
        );

        $this->assertSame([], $api->calls);
        $this->assertFalse($res[0]['status']);
        $this->assertStringContainsString('"5.0" is not a valid campaign id', $res[0]['message']);
    }

    public function testATextualIdPlaceholderStillCreates(): void
    {
        // Only numeric-looking ids are refused; anything else behaves as before.
        $api = new RecordingApiClient();
        $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type\nNEW,Summer,50,0.05,display\n")
        );

        $this->assertSame([['post', 'campaigns']], $api->calls);
    }

    public function testCreativesImportPreChecksTypeAndSaysWhyTheAnswerIsShort(): void
    {
        // The API demands `type` inline, ahead of its rule set, and answers a bare
        // "field type is required" naming neither the row nor the column. With no
        // type there is no per-type list to report either, so say so.
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCreativesFromCsv(
            $this->csv("id,name,type,status\n,QA Creative,,A\n")
        );

        $this->assertSame([], $api->calls, 'the row must not reach the API');
        $this->assertSame(
            'Row 2: cannot create creative — missing required column: type.'
                . ' Fill in type first — it decides which other columns this creative needs.',
            $res[0]['message']
        );
    }

    public function testCreativesImportNamesEveryColumnTheTypeRequires(): void
    {
        // A text creative needs six columns. The API could only ever refuse one at
        // a time, so this used to take five uploads to discover.
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCreativesFromCsv(
            $this->csv("id,name,type,status\n,,text,\n")
        );

        $this->assertSame([], $api->calls);
        $this->assertSame(
            'Row 2: cannot create creative — missing required columns:'
                . ' name, destination, title, description, display_url.',
            $res[0]['message']
        );
    }

    public function testEachCreativeTypeAsksForItsOwnColumns(): void
    {
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCreativesFromCsv(
            $this->csv("id,name,type\n,QA,script\n,QA,image\n,QA,instl\n")
        );

        $this->assertSame([], $api->calls);
        $this->assertSame('Row 2: cannot create creative — missing required columns: content, size.', $res[0]['message']);
        $this->assertSame('Row 3: cannot create creative — missing required columns: destination, image_url, size.', $res[1]['message']);
        $this->assertSame('Row 4: cannot create creative — missing required columns: destination, image_url.', $res[2]['message']);
    }

    public function testAMisspeltCreativeTypeIsLeftForTheApiToRefuse(): void
    {
        // The type vocabulary lives in the API's helper; a client-side copy would
        // drift. "Text" is not text there either — the check is case-sensitive.
        $api = new RecordingApiClient();
        $this->service($api)->importCreativesFromCsv(
            $this->csv("id,name,type\n,QA,Text\n")
        );

        $this->assertSame([['post', 'creatives']], $api->calls);
    }

    public function testACompleteCreativeRowStillReachesTheApi(): void
    {
        $api = new RecordingApiClient();
        $this->service($api)->importCreativesFromCsv($this->csv(
            "id,name,type,destination,title,description,display_url\n"
                . ",QA,text,http://example.com/,T,D,www.example.com\n"
        ));

        $this->assertSame([['post', 'creatives']], $api->calls);
    }

    public function testCreativesUpdateWithNoTypeStillReachesTheApi(): void
    {
        // The pre-check is create-only: ad ops export, edit one column, re-upload.
        $api = new RecordingApiClient();
        $this->service($api)->importCreativesFromCsv(
            $this->csv("id,name,type,status\n4321,QA Creative,,A\n")
        );

        $this->assertSame([['patch', 'creatives/4321']], $api->calls);
    }

    public function testAWhitespaceOnlyRowIsSkippedNotReported(): void
    {
        // A row holding one space is not a row anyone wrote. It used to survive
        // the truthiness test and be reported as a hard error.
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type,targeting_countries\n,Summer,50,0.05,display,USA\n , , , , , \n")
        );

        $this->assertCount(1, $res, 'only the real row should be reported');
        $this->assertTrue($res[0]['status']);
    }

    public function testARowWhoseOnlyValueIsZeroIsNotSilentlyDropped(): void
    {
        // array_filter() treated "0" as empty, so this row vanished without a word.
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type,targeting_countries\n,0,,,,\n")
        );

        $this->assertCount(1, $res, 'the row must be reported, not dropped');
        $this->assertFalse($res[0]['status']);
        $this->assertStringContainsString('Row 2:', $res[0]['message']);
    }

    public function testASuccessfulRowCarriesItsCsvRowNumber(): void
    {
        // So a success and an unrelated failure in one upload are attributable.
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type,targeting_countries\n,Summer,50,0.05,display,USA\n")
        );

        $this->assertSame(2, $res[0]['row']);
    }

    public function testRowNumbersMatchTheSpreadsheetAndSkipBlankLines(): void
    {
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type,targeting_countries\n,Summer,50,0.05,display,USA\n\n,,,,,USA\n")
        );

        // Header is row 1, the valid row is 2, the blank line is skipped without
        // consuming a number, and the empty row is 4.
        $this->assertStringContainsString('Row 4:', $res[1]['message']);
    }

    public function testARejectedStatusReadsAsPlainTextNotRawJson(): void
    {
        // P is Pending — a real campaign.status code, but the API accepts only A
        // and S from an advertiser, so it answers 422 with a field-by-field
        // errors list and no top-level message. That list used to reach the user
        // as raw JSON: `0: {"field":"status","message":"..."}`.
        $api = new ValidationFailureApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type,targeting_countries,status\n,Summer,50,0.05,display,USA,P\n")
        );

        $this->assertSame('1 problem to fix (CSV row 2):', $res[0]['message']);
        $this->assertStringNotContainsString('{', $res[0]['message']);
        // The detail belongs in the sub-list, keyed by the column to edit.
        $this->assertSame(
            [['field' => 'status', 'message' => 'The Status field must be one of: A,S.']],
            $res[0]['errors']
        );
    }

    public function testARejectedCreativeStatusAlsoReadsAsPlainText(): void
    {
        // Same 422 shape on the creatives path, whose results list is separate
        // markup — it printed the same raw JSON and has to stay fixed too.
        $api = new ValidationFailureApiClient();
        $res = $this->service($api)->importCreativesFromCsv(
            $this->csv(
                "id,name,type,status,destination,title,description,display_url\n"
                    . ",QA Creative,text,P,http://example.com/,T,D,www.example.com\n"
            )
        );

        $this->assertSame([['post', 'creatives']], $api->calls);
        $this->assertSame('1 problem to fix (CSV row 2):', $res[0]['message']);
        $this->assertStringNotContainsString('{', $res[0]['message']);
    }

    public function testACreateNamesTheRecordItJustMinted(): void
    {
        // Campaigns have no duplicate check, so uploading the same CSV twice is
        // allowed and each run creates another record. The API says only
        // "Campaign has been added" either way, so the id is what distinguishes
        // a second upload from a no-op.
        // The legacy shape: 201, a flat "has been added", and the new id.
        $api = new StubCreateApiClient([
            'status' => 201,
            'body'   => ['status' => true, 'id' => 12345, 'message' => 'Campaign has been added'],
        ]);
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type,targeting_countries\n,Summer,50,0.05,display,USA\n")
        );

        $this->assertTrue($res[0]['status']);
        $this->assertSame('Campaign has been added — new record, ID:12345', $res[0]['message']);
    }

    public function testTheRecordIdIsNotRepeatedWhenTheApiAlreadyGaveIt(): void
    {
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type,targeting_countries\n,Summer,50,0.05,display,USA\n")
        );

        $this->assertSame('Campaign has been created. ID:1', $res[0]['message']);
    }

    public function testTheNewRecordIdSurvivesAMessageCarryingOtherDigits(): void
    {
        // Matching the bare number would find the "25" inside "2025" and skip the
        // id, which is the one thing this message exists to say.
        $api = new StubCreateApiClient([
            'status' => 201,
            'body'   => ['status' => true, 'id' => 25, 'message' => 'Campaign has been added for the 2025 season'],
        ]);
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type,targeting_countries\n,Summer,50,0.05,display,USA\n")
        );

        $this->assertSame('Campaign has been added for the 2025 season — new record, ID:25', $res[0]['message']);
    }

    public function testAnArrayMessageReachesTheRendererInsteadOfBecomingTheWordArray(): void
    {
        // Casting here would flatten the array to "Array" before the results list
        // could describe it — and CI4 promotes the conversion warning to a thrown
        // ErrorException, taking the whole batch's flashdata results with it.
        $api = new StubCreateApiClient([
            'status' => 201,
            'body'   => [
                'status'  => true,
                'id'      => 999,
                'message' => [['field' => 'status', 'message' => 'The Status field must be one of: A,S.']],
            ],
        ]);
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type,targeting_countries\n,Summer,50,0.05,display,USA\n")
        );

        $this->assertIsArray($res[0]['message']);
        $this->assertSame(
            'Field-status (The Status field must be one of: A,S.)',
            ImportExportService::messageText($res[0]['message'])
        );
    }

    public function testAlphabeticBidAndBudgetReadAsPlainTextNotRawJson(): void
    {
        // Both API rules reject a non-numeric by type before any range check
        // (greater_than_equal_to[2] for budget, bid_validation for bid), so the
        // answer is one 422 carrying two field errors.
        $api = new StubCreateApiClient([
            'status' => 422,
            'body'   => [
                'status' => false,
                'errors' => [
                    ['field' => 'budget', 'message' => 'The Daily Budget field must contain a number greater than or equal to 2.'],
                    ['field' => 'bid', 'message' => 'Rate value must be in the range: 0.001 - 100'],
                ],
            ],
        ]);
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type,targeting_countries\n,QA Alpha,xyz,abc,display,USA\n")
        );

        $this->assertSame('2 problems to fix (CSV row 2):', $res[0]['message']);
        $this->assertStringNotContainsString('{', $res[0]['message']);
        // The detail stays in the sub-list, keyed by the CSV column to edit.
        $this->assertSame('budget', $res[0]['errors'][0]['field']);
        $this->assertSame('bid', $res[0]['errors'][1]['field']);
    }

    public function testARowOfNonBreakingSpacesIsSkippedNotCreated(): void
    {
        // A trailing row pasted over from a web page or Word keeps NBSP, which
        // trim() does not strip. It used to clear the required-column check and
        // POST as a create, turning one real row into two campaigns.
        $nbsp = "\xC2\xA0";
        $api  = new RecordingApiClient();
        $res  = $this->service($api)->importCampaignsFromCsv($this->csv(
            "id,name,budget,bid,type,targeting_countries\n"
            . ",Summer,50,0.05,display,USA\n"
            . ",{$nbsp},{$nbsp},{$nbsp},{$nbsp},{$nbsp}\n"
        ));

        $this->assertSame([['post', 'campaigns']], $api->calls, 'only the real row may be sent');
        $this->assertCount(1, $res);
    }

    public function testAZeroWidthSpaceRowIsAlsoSkipped(): void
    {
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv($this->csv(
            "id,name,budget,bid,type,targeting_countries\n"
            . ",Summer,50,0.05,display,USA\n"
            . "\xE2\x80\x8B,,,,,\n"
        ));

        $this->assertCount(1, $res);
    }

    public function testANonBreakingSpaceDoesNotSatisfyARequiredColumn(): void
    {
        // Same rule where it is decided whether a column was filled in.
        $this->assertSame(
            ['name'],
            ImportExportService::missingRequired(['name' => "\xC2\xA0"], ['name'])
        );
    }

    // ───────────────────────────── campaign ownership and type compatibility

    private function contextService(RecordingApiClient $api): ImportExportService
    {
        return new ContextStubService($api, new SilentEventLog());
    }

    public function testACampaignFromAnotherAdvertiserStopsTheRow(): void
    {
        // The API never checked: `campaigns` is dropped from its rule set before
        // validation and linkCampaigns() writes the raw id, so a creative could be
        // attached to another advertiser's campaign — and the serving cache joins
        // that table with no advertiser predicate.
        $api = new RecordingApiClient();
        $res = $this->contextService($api)->importCreativesFromCsv(
            $this->csv("id,type,campaigns\n55,text,999999\n")
        );

        $this->assertSame([], $api->calls, 'the row must not be sent');
        $this->assertSame(
            'Row 2: no campaign with id 999999 is available for your account —'
                . ' check the campaigns column. Nothing on this row was changed.',
            $res[0]['message']
        );
    }

    public function testTheAdvertisersOwnCampaignIsAccepted(): void
    {
        $api = new RecordingApiClient();
        $this->contextService($api)->importCreativesFromCsv(
            $this->csv("id,type,campaigns\n55,text,4001\n")
        );

        $this->assertSame([['patch', 'creatives/55']], $api->calls);
    }

    public function testAnIncompatibleCampaignTypeWithholdsTheWholeCellRatherThanFailing(): void
    {
        // Withheld whole, never one id out of the list: linkCampaigns deletes every
        // link before re-inserting what it is sent, so filtering would unlink.
        $api = new PayloadRecordingApiClient();
        $res = $this->contextService($api)->importCreativesFromCsv(
            $this->csv("id,type,campaigns\n55,text,4002\n")
        );

        $this->assertSame([['patch', 'creatives/55']], $api->calls);
        $this->assertArrayNotHasKey('campaigns', $api->payloads[0]);
        $this->assertStringContainsString(
            'campaign 4002 is a display campaign and cannot run a text creative — only image and script creatives can',
            (string) ImportExportService::describeIgnored($res[0]['ignored'])
        );
    }

    public function testAnUnjudgeableCampaignTypeIsLeftAlone(): void
    {
        // 3,030 live links sit on campaign_type/campaign_mode combinations that have
        // no row in campaign_type. No opinion is not the same as not allowed.
        $api = new PayloadRecordingApiClient();
        $res = $this->contextService($api)->importCreativesFromCsv(
            $this->csv("id,type,campaigns\n55,text,4003\n")
        );

        $this->assertSame(['4003'], $api->payloads[0]['campaigns']);
        $this->assertNull(ImportExportService::describeIgnored($res[0]['ignored']));
    }

    public function testANonNumericCampaignIdCannotSlipPastTheOwnershipCheck(): void
    {
        // campaign_creative_link.campaign_id is an unsigned int on a non-strict
        // server, so "999x", "+999", "999.0" and "999 999" all coerce to 999 on
        // insert. Checking only the digit-only tokens left that as a way in.
        foreach (['999x', '+999', '999.0', '999 999'] as $smuggled) {
            $api = new PayloadRecordingApiClient();
            $res = $this->contextService($api)->importCreativesFromCsv(
                $this->csv("id,type,campaigns\n55,text," . $smuggled . "\n")
            );

            $this->assertSame([], $api->calls, $smuggled . ' must not reach the API');
            $this->assertStringContainsString('is not a campaign id', $res[0]['message'], $smuggled);
        }
    }

    public function testANonNumericCampaignIdIsNeverForwardedEvenWithoutAdvertiserContext(): void
    {
        // Belt and braces: with no request context the ownership check stands down,
        // so the decoder has to withhold the cell on its own.
        $payload = CreativeCsv::decodeRow(['id' => '55', 'type' => 'text', 'campaigns' => '999x'], $ignored);

        $this->assertArrayNotHasKey('campaigns', $payload);
        $this->assertStringContainsString('not a campaign id: 999x', $ignored[0]['reason']);
    }

    public function testARepeatedForeignCampaignIdIsReportedOnce(): void
    {
        $api = new RecordingApiClient();
        $res = $this->contextService($api)->importCreativesFromCsv(
            $this->csv("id,type,campaigns\n55,text,\"999,999,999\"\n")
        );

        $this->assertSame(
            'Row 2: no campaign with id 999 is available for your account —'
                . ' check the campaigns column. Nothing on this row was changed.',
            $res[0]['message']
        );
    }

    public function testThousandsOfForeignIdsProduceOneShortMessage(): void
    {
        // 262,144 ids fit inside the 2 MB upload limit; one message each, imploded,
        // exhausted the 128 MB memory limit as an uncatchable fatal and lost the
        // whole batch.
        $ids = implode(',', range(900000, 903000));
        $api = new RecordingApiClient();
        $res = $this->contextService($api)->importCreativesFromCsv(
            $this->csv("id,type,campaigns\n55,text,\"" . $ids . "\"\n")
        );

        $this->assertCount(1, $res);
        $this->assertLessThan(400, strlen($res[0]['message']));
        $this->assertStringContainsString('and 2996 more', $res[0]['message']);
    }

    public function testARefusedRowDoesNotAlsoClaimACellWasKept(): void
    {
        // "the stored type was kept" under "Nothing on this row was changed" reads as
        // "the update went through, minus that cell". Nothing was sent at all.
        $api = new RecordingApiClient();
        $res = $this->contextService($api)->importCreativesFromCsv(
            $this->csv("id,type,campaigns\n56,text,999\n")
        );

        $this->assertSame([], $api->calls);
        $this->assertFalse($res[0]['status']);
        $this->assertNull(ImportExportService::describeIgnored($res[0]['ignored'] ?? null));
    }

    public function testABogusTypeIsNotDescribedAsARealOne(): void
    {
        // "To change the type, create a new creative" is only sound advice when the
        // cell holds a creative type. "banner" is not one, and neither is "Text".
        $api = new RecordingApiClient();
        $res = $this->contextService($api)->importCreativesFromCsv(
            $this->csv("id,type,title\n55,banner,New title\n")
        );

        $line = (string) ImportExportService::describeIgnored($res[0]['ignored']);
        $this->assertStringContainsString('"banner" is not a creative type', $line);
        $this->assertStringNotContainsString('create a new creative', $line);
    }

    public function testAWithheldCellOnATwoColumnRowIsNotCalledAFormatFault(): void
    {
        $api = new RecordingApiClient();
        $res = $this->contextService($api)->importCreativesFromCsv(
            $this->csv("id,campaigns\n55,4002\n")
        );

        $this->assertSame([], $api->calls);
        $this->assertSame(
            'Row 2: nothing was sent for creative 55 — the only column with a value was set aside.',
            $res[0]['message']
        );
        $this->assertStringContainsString('cannot run a text creative', (string) ImportExportService::describeIgnored($res[0]['ignored']));
    }

    public function testACreateRowIsAlsoCheckedForTypeCompatibility(): void
    {
        // The API checks neither, and a new buildout is the common create path.
        $api = new PayloadRecordingApiClient();
        $res = $this->contextService($api)->importCreativesFromCsv($this->csv(
            "id,type,name,destination,title,description,display_url,campaigns\n"
                . ",text,QA,http://x.test/,T,D,www.example.com,4002\n"
        ));

        $this->assertSame([['post', 'creatives']], $api->calls);
        $this->assertArrayNotHasKey('campaigns', $api->payloads[0]);
        $this->assertStringContainsString(
            'campaign 4002 is a display campaign and cannot run a text creative',
            (string) ImportExportService::describeIgnored($res[0]['ignored'])
        );
    }

    public function testEveryMismatchedCampaignIsNamed(): void
    {
        $api = new RecordingApiClient();
        $res = $this->contextService($api)->importCreativesFromCsv(
            $this->csv("id,type,campaigns\n55,text,\"4001,4002,4004\"\n")
        );

        $line = (string) ImportExportService::describeIgnored($res[0]['ignored']);
        $this->assertStringContainsString('4002', $line);
        $this->assertStringContainsString('4004', $line);
    }

    // ───────────────────────────── geo targeting and display URL preservation

    public function testACampaignUpdateKeepsItsCountriesAndMetro(): void
    {
        // Targeting_model::prepare() force-injects countries and metro whatever the
        // payload holds, and the API calls it on every patch — so a row with no
        // targeting_countries column reset the campaign to USA and dropped metro.
        $api = new PayloadRecordingApiClient();
        $this->contextService($api)->importCampaignsFromCsv(
            $this->csv("id,budget\n77,50\n")
        );

        $this->assertSame(['CAN', 'GBR'], $api->payloads[0]['targeting']['countries']);
        $this->assertSame(['501'], $api->payloads[0]['targeting']['metro']);
    }

    public function testARowThatSetsCountriesItselfIsLeftAlone(): void
    {
        $api = new PayloadRecordingApiClient();
        $this->contextService($api)->importCampaignsFromCsv(
            $this->csv("id,targeting_countries\n77,USA\n")
        );

        $this->assertSame(['USA'], $api->payloads[0]['targeting']['countries']);
    }

    public function testACreativeUpdateKeepsItsDisplayUrlWhenOnlyTheDestinationChanges(): void
    {
        // Creative_model back-fills display_url from the destination host when the key
        // is absent, so dropping the blank cell would overwrite the stored value with
        // the tracking host.
        $api = new PayloadRecordingApiClient();
        $this->contextService($api)->importCreativesFromCsv(
            $this->csv("id,type,destination,display_url\n55,text,https://tracking.partner.net/click?id=9,\n")
        );

        $this->assertSame('ShopNow.com', $api->payloads[0]['display_url']);
    }

    // ───────────────────────────── the type cell on an update

    public function testAnEditedTypeOnAnUpdateSaysItWasIgnored(): void
    {
        $api = new RecordingApiClient();
        $res = $this->contextService($api)->importCreativesFromCsv(
            $this->csv("id,type,title\n55,image,New title\n")
        );

        $this->assertSame([['patch', 'creatives/55']], $api->calls);
        $this->assertStringContainsString(
            'type (this creative is stored as a text creative and the type cannot be changed on an update,'
                . ' so "image" was ignored and the stored type was kept',
            (string) ImportExportService::describeIgnored($res[0]['ignored'])
        );
    }

    public function testAnUnchangedTypeOnAnUpdateSaysNothing(): void
    {
        $api = new RecordingApiClient();
        $res = $this->contextService($api)->importCreativesFromCsv(
            $this->csv("id,type,title\n55,text,New title\n")
        );

        $this->assertNull(ImportExportService::describeIgnored($res[0]['ignored']));
    }

    public function testAVideoCreativeCannotBeUpdatedThroughImport(): void
    {
        // The API has no `video` entry in its type-field map, so the patch hands NULL
        // to set_data() and dies as a 500 with nothing saved.
        $api = new RecordingApiClient();
        $res = $this->contextService($api)->importCreativesFromCsv(
            $this->csv("id,type,title\n56,video,New title\n")
        );

        $this->assertSame([], $api->calls);
        $this->assertSame(
            'Row 2: creative 56 is a video creative and cannot be updated through import —'
                . ' edit it in Creative Management.',
            $res[0]['message']
        );
    }

    // ───────────────────────────── empty files

    public function testAHeaderWithNoRowsUnderItSaysSo(): void
    {
        // The page used to come back with nothing at all: the row loop never ran,
        // the service returned [], and both partials hide the whole results block
        // when that list is empty.
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv($this->csv("id,name,budget,bid,type\n"));

        $this->assertSame([], $api->calls);
        $this->assertFalse($res[0]['status']);
        $this->assertSame(
            'No data rows found — the file has a header row but nothing below it.'
                . ' Add one row per campaign under the header and upload again.',
            $res[0]['message']
        );
    }

    public function testTheCreativesTabGetsTheSameAnswer(): void
    {
        $res = $this->service(new RecordingApiClient())->importCreativesFromCsv($this->csv("id,name,type\n"));

        $this->assertStringContainsString('Add one row per creative under the header', $res[0]['message']);
    }

    public function testABlankLineUnderTheHeaderIsStillNoDataRows(): void
    {
        $res = $this->service(new RecordingApiClient())->importCampaignsFromCsv($this->csv("id,name,budget,bid,type\n,,,,\n"));

        $this->assertCount(1, $res);
        $this->assertStringContainsString('No data rows found', $res[0]['message']);
    }

    public function testAnUnrecognisedHeaderWithNoRowsGetsBothAnswers(): void
    {
        // Keyed on the row count, not on the response list being empty, so the
        // header warning does not mask the reason nothing was imported.
        $res = $this->service(new RecordingApiClient())->importCampaignsFromCsv($this->csv("id,budget,nonsense\n"));

        $this->assertSame('warning', $res[0]['type']);
        $this->assertStringContainsString('nonsense', $res[0]['message']);
        $this->assertStringContainsString('No data rows found', $res[1]['message']);
    }

    public function testARealRowStillReportsNoEmptyFileError(): void
    {
        $res = $this->service(new RecordingApiClient())->importCampaignsFromCsv($this->csv("id,budget\n77,50\n"));

        $this->assertCount(1, $res);
        $this->assertStringNotContainsString('No data rows', (string) $res[0]['message']);
    }

    public function testABlankStatusOnAnUpdateDoesNotReactivateAPausedRecord(): void
    {
        // Re-uploading an export with the status cell cleared used to send A, which
        // un-pauses a stopped campaign without a word.
        $api = new PayloadRecordingApiClient();
        $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,status,budget\n77,,50\n")
        );

        $this->assertArrayNotHasKey('status', $api->payloads[0]);

        $api = new PayloadRecordingApiClient();
        $this->service($api)->importCreativesFromCsv(
            $this->csv("id,type,status,title\n55,text,,T\n")
        );

        $this->assertArrayNotHasKey('status', $api->payloads[0]);
    }

    public function testABlankStatusOnACreateStillDefaultsToActive(): void
    {
        // Which is what the field guide documents.
        $api = new PayloadRecordingApiClient();
        $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type,status\n,Summer,50,0.05,display,\n")
        );

        $this->assertSame('A', $api->payloads[0]['status']);
    }

    public function testAFilledStatusIsStillSentOnAnUpdate(): void
    {
        $api = new PayloadRecordingApiClient();
        $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,status\n77,S\n")
        );

        $this->assertSame('S', $api->payloads[0]['status']);
    }

    // ───────────────────────────── header + dropped-cell warnings

    /** @return array<int, array<string, mixed>> */
    private function warnings(array $responses): array
    {
        return array_values(array_filter($responses, static function (array $r): bool {
            return ($r['type'] ?? '') === 'warning';
        }));
    }

    public function testAMisspeltHeaderIsReportedOnceAndTheRestOfTheRowStillImports(): void
    {
        // The case QA could not tell apart from a working import: the column is
        // inert, so every row came back green while the value went nowhere.
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,budget,targeting_frequancy\n77,50,50\n")
        );

        $warnings = $this->warnings($res);
        $this->assertCount(1, $warnings);
        $this->assertStringContainsString('targeting_frequancy', $warnings[0]['message']);
        $this->assertSame([['patch', 'campaigns/77']], $api->calls, 'An unknown column must not stop the import');
    }

    public function testAHeaderInTheWrongCaseIsReported(): void
    {
        // Rows are keyed by the header verbatim, so "Budget" is not "budget".
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,Budget\n77,50\n")
        );

        $this->assertStringContainsString('Budget', $this->warnings($res)[0]['message']);
    }

    public function testADuplicateHeaderIsReported(): void
    {
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,budget,budget\n77,50,60\n")
        );

        $this->assertStringContainsString('Duplicate column: budget', $this->warnings($res)[0]['message']);
    }

    public function testAnUnnamedHeaderColumnIsReported(): void
    {
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,budget,\n77,50,x\n")
        );

        $this->assertStringContainsString('1 unnamed column', $this->warnings($res)[0]['message']);
    }

    public function testUnnamedHeaderColumnsAreCountedNotCollapsed(): void
    {
        // Four trailing commas are four columns the importer cannot read.
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv($this->csv("id,budget,,,,\n77,50,a,b,c,d\n"));

        $this->assertStringContainsString('4 unnamed columns', $this->warnings($res)[0]['message']);
    }

    public function testAKnownHeaderIsNotReported(): void
    {
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type,targeting_frequency\n77,Summer,50,0.05,display,50\n")
        );

        $this->assertSame([], $this->warnings($res));
    }

    public function testAHearstRollupColumnIsNotReported(): void
    {
        // A Hearst export carries the three rollups; re-importing it unchanged
        // must not warn about columns our own exporter wrote.
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,budget,total_impressions,total_clicks,total_budget\n77,50,10,2,99\n")
        );

        $this->assertSame([], $this->warnings($res));
    }

    public function testACreativesHeaderIsCheckedToo(): void
    {
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCreativesFromCsv(
            $this->csv("id,titel\n77,Buy now\n")
        );

        $this->assertStringContainsString('titel', $this->warnings($res)[0]['message']);
    }

    public function testADroppedCellIsCarriedOnTheRowItCameFrom(): void
    {
        // The row is a success — budget went through — but the frequency cell did
        // not, and that used to be invisible.
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,budget,targeting_frequency\n77,50,abc\n")
        );

        $this->assertSame([['patch', 'campaigns/77']], $api->calls);
        $this->assertTrue($res[0]['status']);
        $this->assertSame('targeting_frequency', $res[0]['ignored'][0]['column']);
        $this->assertStringContainsString(
            'targeting_frequency ("abc" is not a number',
            ImportExportService::describeIgnored($res[0]['ignored'])
        );
    }

    public function testARowWhoseOnlyFilledCellWasIgnoredSaysWhy(): void
    {
        // Decoding leaves nothing to send. "wrong CSV format" sent the user looking
        // for a formatting problem; the real answer is the cell that was dropped.
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,targeting_frequency\n77,abc\n")
        );

        $this->assertSame([], $api->calls);
        $this->assertFalse($res[0]['status']);
        // `id,campaigns` is a real two-column file an operator builds to re-link in
        // bulk, so a deliberately withheld cell must not read as a format fault.
        $this->assertSame(
            'Row 2: nothing was sent for campaign 77 — the only column with a value was set aside.',
            $res[0]['message']
        );
        $this->assertSame('targeting_frequency', $res[0]['ignored'][0]['column']);
    }

    public function testARowThatUsedEveryCellCarriesNoIgnoredList(): void
    {
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,budget,targeting_frequency\n77,50,50\n")
        );

        $this->assertSame([], $res[0]['ignored']);
        $this->assertNull(ImportExportService::describeIgnored($res[0]['ignored']));
    }

    public function testAnInvalidDisplayUrlStopsANewCreativeBeforeTheApi(): void
    {
        // The API has no URL rule for display_url, so nothing downstream would
        // catch this — the junk would be stored and the row reported green.
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCreativesFromCsv(
            $this->csv("id,type,name,destination,title,description,display_url\n,text,QA,http://x.test/,T,D,N/A\n")
        );

        $this->assertSame([], $api->calls);
        $this->assertFalse($res[0]['status']);
        $this->assertStringContainsString('Row 2: "N/A" is not a usable display URL', $res[0]['message']);
    }

    public function testAnUpdateWithAJunkDisplayUrlStillAppliesTheRestOfTheRow(): void
    {
        // 281 active creatives store a display URL that is not one. The unrelated
        // edit must land; the junk must not be written; the user must be told.
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCreativesFromCsv(
            $this->csv("id,type,status,display_url\n55,text,S,Grab the Deal\n")
        );

        $this->assertSame([['patch', 'creatives/55']], $api->calls);
        $this->assertTrue($res[0]['status']);
        $this->assertStringContainsString(
            'display_url ("Grab the Deal" is not a usable display URL',
            (string) ImportExportService::describeIgnored($res[0]['ignored'])
        );
    }

    public function testASchemelessDisplayUrlStillReachesTheApi(): void
    {
        // 82.5% of stored display URLs have no scheme; the check must not be a
        // stricter copy of a rule the platform does not have.
        $api = new RecordingApiClient();
        $this->service($api)->importCreativesFromCsv(
            $this->csv("id,type,display_url\n55,text,www.example.com/shop\n")
        );

        $this->assertSame([['patch', 'creatives/55']], $api->calls);
    }

    public function testATextParentCreativeIdStopsTheRowWithAReadableMessage(): void
    {
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCreativesFromCsv(
            $this->csv(
                "id,type,name,destination,title,description,display_url,parent_creative_id\n"
                    . ",text,QA,http://x.test/,T,D,www.example.com,adschedule1\n"
            )
        );

        $this->assertSame([], $api->calls);
        $this->assertSame(
            'Row 2: "adschedule1" is not a valid parent creative id —'
                . ' use the numeric id of the parent creative, or clear the column.',
            $res[0]['message']
        );
        $this->assertStringNotContainsString('{', $res[0]['message']);
    }

    public function testCellsTheCreativeTypeDropsComeBackAsWarningsOnASuccessfulRow(): void
    {
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCreativesFromCsv($this->csv(
            "id,type,name,destination,image_url,size,parent_creative_id,rank\n"
                . ",image,QA,http://x.test/,http://x.test/i.png,300x250,7,3\n"
        ));

        $this->assertSame([['post', 'creatives']], $api->calls);
        $this->assertTrue($res[0]['status']);
        $this->assertStringContainsString(
            'parent_creative_id (type "image" has no parent_creative_id',
            (string) ImportExportService::describeIgnored($res[0]['ignored'])
        );
    }

    public function testCampaignImportHasNoRowValidator(): void
    {
        // Campaigns pass no validator, so the loop must behave exactly as before.
        $api = new RecordingApiClient();
        $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,budget\n77,50\n")
        );

        $this->assertSame([['patch', 'campaigns/77']], $api->calls);
    }

    // ───────────────────────────── readable messages, whatever the API sends

    public function testAnInvalidDestinationUrlReadsAsAValidationMessage(): void
    {
        // The exact 422 the API answers for destination: trim|required|max_length|url.
        $body = ['status' => false, 'errors' => [
            ['field' => 'destination', 'message' => 'Destination URL value must be valid URL'],
        ]];

        $this->assertSame(
            '1 problem to fix (CSV row 2):',
            ImportExportService::describeFailure(['status' => 422], $body, 2, 'creative', false)
        );
        $this->assertSame(
            'Field-destination (Destination URL value must be valid URL)',
            ImportExportService::describeErrors($body['errors'])
        );
    }

    public function testANestedErrorMessageIsReadRatherThanCrashingTheBatch(): void
    {
        // Interpolating an array casts it to "Array", and CI4 turns that warning
        // into a thrown ErrorException — a 500 that loses every other row's result.
        $errors = [['field' => 'destination', 'message' => ['url' => 'must be a valid URL']]];

        $this->assertSame(
            'Field-destination (must be a valid URL)',
            ImportExportService::describeErrors($errors)
        );
    }

    public function testAnUnrecognisedErrorShapeStillYieldsItsText(): void
    {
        // Dropping the entry left a blank bullet under "1 problem to fix".
        $this->assertSame(
            'destination: must be a url',
            ImportExportService::messageText(['destination' => ['must be a url']])
        );
        $this->assertSame(1, ImportExportService::countProblems(['destination' => ['must be a url']]));
    }

    public function testANonStringReasonIsNotDiscarded(): void
    {
        $detail = ImportExportService::describeFailure(
            ['status' => 400],
            ['status' => false, 'message' => ['destination' => 'is not a URL']],
            2,
            'creative',
            false
        );

        $this->assertSame('request failed with HTTP 400 (CSV row 2): is not a URL', $detail);
    }

    public function testMarkupInAnApiMessageIsNotShownToTheUser(): void
    {
        // The SA360 path wraps its warning in <strong style="color:#f00;">, and the
        // view escapes what it is handed, so the tags were rendered literally.
        $api = new MarkupMessageApiClient();
        $res = $this->service($api)->importCreativesFromCsv(
            $this->csv("id,type,status\n55,text,S\n")
        );

        $this->assertSame('Creative has been added but SA360 sync failed', $res[0]['message']);
    }

    public function testTheImportersOwnMessagesKeepTheValueTheyQuote(): void
    {
        // messageText() runs over our messages too, and they quote the user's cell:
        // strip_tags() ate everything from "<" to the end, so a display_url of
        // "<100abc" rendered as `Row 2: "` — value and reason both gone.
        $api = new RecordingApiClient();
        $res = $this->service($api)->importCreativesFromCsv(
            $this->csv("id,type,name,destination,title,description,display_url\n,text,QA,http://x.test/,T,D,<100abc\n")
        );

        $this->assertStringContainsString('"<100abc" is not a usable display URL', $res[0]['message']);
        $this->assertSame($res[0]['message'], ImportExportService::messageText($res[0]['message']));

        // Same for a header name echoed back in the header warning.
        $res = $this->service(new RecordingApiClient())->importCreativesFromCsv(
            $this->csv("id,<div\n55,x\n")
        );
        $this->assertStringContainsString('<div', ImportExportService::messageText($res[0]['message']));
    }

    public function testNoErrorShapeEverRendersAsJson(): void
    {
        $shapes = [
            [['field' => 'destination', 'message' => 'Destination URL value must be valid URL']],
            [['field' => 'rank', 'message' => 'The rank field is required.']],
            'Rank should be blank if parent_creative_id is not present !!',
            [['field' => ['a', 'b'], 'message' => 'still readable']],
            [['field' => 'x', 'message' => [['field' => 'y', 'message' => 'two levels down']]]],
            ['destination' => ['must be a url']],
            [[]],
        ];

        foreach ($shapes as $i => $shape) {
            $text = (string) ImportExportService::messageText($shape);
            $this->assertStringNotContainsString('{"', $text, "shape {$i}");
            $this->assertStringNotContainsString('Array', $text, "shape {$i}");
        }
    }

    public function testAFlatStringErrorReadsAsOneSentenceNotAHeadingAndABullet(): void
    {
        // The rank-without-parent 400 answers `errors` as a bare string. The heading
        // said "1 problem to fix" and the view rendered the same string underneath,
        // so the reason appeared twice.
        $body = ['status' => false, 'errors' => 'Rank should be blank if parent_creative_id is not present !!'];

        $this->assertSame(
            'Rank should be blank if parent_creative_id is not present !! (CSV row 2)',
            ImportExportService::describeFailure(['status' => 400], $body, 2, 'creative', false)
        );
    }

    public function testAPerFieldErrorListStillGetsItsHeadingAndBullets(): void
    {
        $body = ['status' => false, 'errors' => [
            ['field' => 'status', 'message' => 'The Status field must be one of: A,S.'],
            ['field' => 'start_date', 'message' => 'Start date format is invalid'],
        ]];

        $this->assertSame(
            '2 problems to fix (CSV row 2):',
            ImportExportService::describeFailure(['status' => 422], $body, 2, 'Campaign', false)
        );
        $this->assertSame(2, ImportExportService::countProblems($body['errors']));
    }

    public function testAnEntryTheViewWillSkipIsNotCounted(): void
    {
        // A heading promising a problem over an empty bullet is the same defect from
        // the other side.
        $this->assertSame(0, ImportExportService::countProblems(['']));
        $this->assertSame(0, ImportExportService::countProblems([[]]));
        // A column with no reason renders as a bare "status:" — the empty bullet both
        // this and errorBullets() promise cannot happen. The sentence carries it.
        $this->assertSame(0, ImportExportService::countProblems([['field' => 'status', 'message' => '']]));
        $this->assertSame(1, ImportExportService::countProblems([['message' => ['nested' => 'reason']]]));
    }

    public function testDescribeIgnoredCountsTheCellsItNames(): void
    {
        $this->assertSame(
            'Ignored 2 cells: click_cap (needs one publisher); targeting_os (not recognised: widnows)',
            ImportExportService::describeIgnored([
                ['column' => 'click_cap', 'reason' => 'needs one publisher'],
                ['column' => 'targeting_os', 'reason' => 'not recognised: widnows'],
            ])
        );
    }

    // ─────────────────────────── QA batch 3 regressions

    public function testANativeCreativeUpdateSendsNoneOfItsBlankOptionalCells(): void
    {
        // QA batch 3 #1: "Optional fields like image URL, title, destination URL,
        // display URL, description are validated as mandatory. Native type upload."
        // Those five are exactly native's optional-on-update set, and they used to be
        // sent as '' — the API builds its patch rule set from the keys it receives, so
        // each blank arrived carrying its own `required` rule and refused the row.
        // They stay required on a CREATE, which the API enforces and the field guide
        // states; the bug was only ever on the update.
        $api = new PayloadRecordingApiClient();
        $this->service($api)->importCreativesFromCsv($this->csv(
            "id,type,name,title,description,display_url,destination,image_url\n"
                . "55,native,,New headline,,,,\n"
        ));

        $this->assertSame([['patch', 'creatives/55']], $api->calls);
        foreach (['name', 'description', 'display_url', 'destination', 'image_url'] as $blank) {
            $this->assertArrayNotHasKey($blank, $api->payloads[0], $blank . ' must not be sent blank');
        }
        $this->assertSame('New headline', $api->payloads[0]['title']);
    }

    public function testOneFilledColumnIsEnoughToUpdateACampaign(): void
    {
        // QA batch 3 #3 and #11: "only ID is mandatory but it shows name bid budgets
        // are mandatory" / "It should be atleast need 1 field to update."
        $api = new PayloadRecordingApiClient();
        $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,name,budget,bid,type,status\n77,,25,,,\n")
        );

        $this->assertSame([['patch', 'campaigns/77']], $api->calls);
        $this->assertSame(['budget' => '25'], $api->payloads[0]);
    }

    public function testANewCreativeIsReportedWithItsId(): void
    {
        // QA batch 3 #14: prod names the new Creative ID in the success message and
        // staging did not. The API answers 201 with {id, message}; the id is appended
        // when its own message does not already carry one.
        $api = new StubCreateApiClient([
            'status' => 201,
            'body'   => ['status' => true, 'id' => 4242, 'message' => 'Creative has been added'],
        ]);
        $res = $this->service($api)->importCreativesFromCsv(
            $this->csv("id,name,type,status,content,size\n,QA,script,A,<script></script>,300x250\n")
        );

        $this->assertTrue($res[0]['status']);
        $this->assertStringContainsString('ID:4242', $res[0]['message']);
    }

    // ─────────────────────────── one answer per row

    public function testASuccessLineDoesNotCarryTheApisCaveat(): void
    {
        // Both creative endpoints build their message as
        // 'Creative has been updated' . $error_message, where $error_message is the
        // SA360 mapping finding — so one string reported a success and a problem at
        // once, on one green line.
        [$message, $caveat] = ImportExportService::splitCaveat(
            'Creative has been updated but SA360 creative (clickserve.dartsearch.net) is not 1 to 1 mapped'
                . ' with a matching campaign. No campaign is linked to this creative.'
        );

        $this->assertSame('Creative has been updated', $message);
        $this->assertStringStartsWith('SA360 creative', (string) $caveat);
    }

    public function testAnOrdinaryMessageContainingButIsLeftAlone(): void
    {
        // Split on the API's own literal joiner, not on anything heuristic.
        [$message, $caveat] = ImportExportService::splitCaveat('Campaign has been updated but nothing else');

        $this->assertSame('Campaign has been updated but nothing else', $message);
        $this->assertNull($caveat);
    }

    public function testANonStringMessageSurvivesTheSplit(): void
    {
        [$message, $caveat] = ImportExportService::splitCaveat(['field' => 'status']);

        $this->assertSame(['field' => 'status'], $message);
        $this->assertNull($caveat);
    }

    public function testASuccessfulRowReportsNoErrorBullets(): void
    {
        // A green "has been updated" above a red list of problems is the
        // contradiction QA reported. When the API says it saved, nothing it also sent
        // is rendered as a failure.
        $api = new SuccessWithErrorsApiClient();
        $res = $this->service($api)->importCampaignsFromCsv($this->csv("id,budget\n77,25\n"));

        $this->assertTrue($res[0]['status']);
        $this->assertSame([], $res[0]['errors']);
    }

    // ─────────────────────────── header structure

    public function testAnUnrelatedCsvIsRefusedAsTheWrongFile(): void
    {
        // It used to be processed row by row, so a sales export came back as one
        // fault per row — which reads as a problem with the data rather than with
        // the file.
        $api = new PayloadRecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("Order ID,Product,Qty\n1001,Widget,3\n")
        );

        $this->assertSame([], $api->calls);
        $this->assertCount(1, $res);
        $this->assertStringContainsString('does not look like a Campaigns CSV', $res[0]['message']);
    }

    public function testAnUnrelatedCsvCarryingAStatusColumnIsStillRefused(): void
    {
        // Adding a `status` column used to be enough to get an unrelated file
        // accepted, and the answer QA saw was a per-row
        // "The Status field must be one of: A, S".
        $api = new PayloadRecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("Order ID,Product,Qty,status\n1001,Widget,3,A\n")
        );

        $this->assertSame([], $api->calls);
        $this->assertCount(1, $res);
        $this->assertStringContainsString('no id column', $res[0]['message']);
        $this->assertStringContainsString('name, budget, bid, type', $res[0]['message']);
    }

    public function testAShortUpdateFileIsStillAccepted(): void
    {
        // `id` plus the one column being changed is the normal way to update, and
        // the header check must not stand in the way of it.
        $api = new PayloadRecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv($this->csv("id,budget\n77,25\n"));

        $this->assertSame([['patch', 'campaigns/77']], $api->calls);
    }

    public function testACreateOnlyFileWithNoIdColumnIsAccepted(): void
    {
        $api = new PayloadRecordingApiClient();
        $this->service($api)->importCampaignsFromCsv(
            $this->csv("name,budget,bid,type\nQA,25,0.5,display\n")
        );

        $this->assertSame([['post', 'campaigns']], $api->calls);
    }

    public function testATwoColumnCreativeRelinkFileIsAccepted(): void
    {
        // The file an operator builds to re-link creatives in bulk.
        $api = new PayloadRecordingApiClient();
        $this->service($api)->importCreativesFromCsv($this->csv("id,campaigns\n55,4001\n"));

        $this->assertSame([['patch', 'creatives/55']], $api->calls);
    }

    public function testTheTemplateMinusAFewColumnsIsReported(): void
    {
        // QA's case: optional headers deleted from the template, and the upload
        // succeeded without a word about them.
        $header = CampaignCsv::templateHeader();
        $kept   = array_values(array_diff($header, ['targeting_zip', 'targeting_os', 'targeting_pacing']));

        $warning = ImportExportService::missingColumnWarning($kept, [
            'template' => $header,
        ]);

        $this->assertNotNull($warning);
        $this->assertStringContainsString('3 of its ' . count($header) . ' columns', $warning);
        $this->assertStringContainsString('targeting_zip', $warning);
    }

    public function testAShortFileIsNotReportedAsAMutilatedTemplate(): void
    {
        // Missing forty columns on purpose. Warning every time would bury the
        // warning that matters — the one naming a column the importer cannot read.
        $this->assertNull(ImportExportService::missingColumnWarning(['id', 'budget'], [
            'template' => CampaignCsv::templateHeader(),
        ]));
    }

    public function testACompleteHeaderIsNotReported(): void
    {
        $this->assertNull(ImportExportService::missingColumnWarning(
            CampaignCsv::templateHeader(),
            ['template' => CampaignCsv::templateHeader()]
        ));
    }

    // ─────────────────────────── flight dates

    public function testARowWhoseEndDatePrecedesItsStartDateIsRefused(): void
    {
        // Both columns' only API rule is `sqlDate`, a format check, so the pair went
        // through and stored a campaign that stops before it starts.
        $api = new PayloadRecordingApiClient();
        $res = $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,start_date,end_date\n77,2026-12-01,2026-01-01\n")
        );

        $this->assertSame([], $api->calls);
        $this->assertStringContainsString(
            'end_date 2026-01-01 is earlier than start_date 2026-12-01',
            $res[0]['message']
        );
    }

    public function testMatchingFlightDatesAreAccepted(): void
    {
        // Equal is legitimate — a one-day campaign.
        $api = new PayloadRecordingApiClient();
        $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,start_date,end_date\n77,2026-06-01,2026-06-01\n")
        );

        $this->assertSame([['patch', 'campaigns/77']], $api->calls);
    }

    public function testAMalformedDateIsLeftForTheApiToAnswer(): void
    {
        // `sqlDate` names the column and says the format is invalid. Comparing two
        // strings of unknown shape here would report the wrong fault.
        $api = new PayloadRecordingApiClient();
        $this->service($api)->importCampaignsFromCsv(
            $this->csv("id,start_date,end_date\n77,2026-12-01,01/01/2026\n")
        );

        $this->assertSame([['patch', 'campaigns/77']], $api->calls);
    }

    public function testANewEndDateIsComparedAgainstTheStoredStartDate(): void
    {
        // Editing one date is the common case, and without the stored counterpart
        // "end before start" walked straight through.
        $api = new PayloadRecordingApiClient();
        $res = (new ContextStubService($api, new SilentEventLog()))->importCampaignsFromCsv(
            $this->csv("id,end_date\n77,2026-05-01\n")
        );

        $this->assertSame([], $api->calls);
        $this->assertStringContainsString(
            'end_date 2026-05-01 is earlier than start_date 2026-06-01',
            $res[0]['message']
        );
    }

    public function testANewStartDateIsComparedAgainstTheStoredEndDate(): void
    {
        $api = new PayloadRecordingApiClient();
        $res = (new ContextStubService($api, new SilentEventLog()))->importCampaignsFromCsv(
            $this->csv("id,start_date\n77,2026-08-01\n")
        );

        $this->assertSame([], $api->calls);
        $this->assertStringContainsString(
            'end_date 2026-06-30 is earlier than start_date 2026-08-01',
            $res[0]['message']
        );
    }

    public function testACampaignWithNoStoredEndDateAcceptsANewStartDate(): void
    {
        $api = new PayloadRecordingApiClient();
        (new ContextStubService($api, new SilentEventLog()))->importCampaignsFromCsv(
            $this->csv("id,start_date\n4001,2030-01-01\n")
        );

        $this->assertSame([['patch', 'campaigns/4001']], $api->calls);
    }

    // ---------------------------------------------------------------- exclude geo

    public function testAPartialExcludeRowKeepsTheExclusionsItDoesNotMention(): void
    {
        // prepExclude() is dispatched by the node's presence and then reads all five
        // sub-keys whether or not they arrived, so a row that fills only
        // targeting_exclude_states used to empty the campaign's excluded cities and
        // ZIPs on its way past. Completed from storage instead.
        $api = new PayloadRecordingApiClient();
        (new ContextStubService($api, new SilentEventLog()))->importCampaignsFromCsv(
            $this->csv("id,targeting_exclude_states\n78,NV|OR\n")
        );

        $this->assertSame([['patch', 'campaigns/78']], $api->calls);
        $exclude = $api->payloads[0]['targeting']['exclude'];
        $this->assertSame(['NV', 'OR'], $exclude['states'], 'the row still wins');
        $this->assertSame(['los angeles:ca'], $exclude['cities']);
        $this->assertSame(['91001'], $exclude['zip']);
        $this->assertSame(['CAN'], $exclude['countries']);
        // Nothing stored, so nothing to carry: prepExclude empties metro either way.
        $this->assertArrayNotHasKey('metro', $exclude);
        // The top-level pair the same method has always preserved.
        $this->assertSame(['USA'], $api->payloads[0]['targeting']['countries']);
    }

    public function testAnExcludeNodeIsNotInventedForARowThatDoesNotSendOne(): void
    {
        // Completing a node is only safe when the row asked for one. Adding it would
        // dispatch prepExclude against a campaign the row never meant to touch.
        $api = new PayloadRecordingApiClient();
        (new ContextStubService($api, new SilentEventLog()))->importCampaignsFromCsv(
            $this->csv("id,targeting_ron\n78,1\n")
        );

        $this->assertArrayNotHasKey('exclude', $api->payloads[0]['targeting']);
    }

    public function testANativeCampaignsExclusionsAreWithheldRatherThanWiped(): void
    {
        // For a type outside prepExclude's $for_types the API does not skip the geo
        // exclusions, it hard-sets each to [] — so sending the node deletes what the
        // campaign has and answers 200. Nothing sendable avoids that, so the node is
        // withheld and the columns are reported.
        $api = new PayloadRecordingApiClient();
        $res = (new ContextStubService($api, new SilentEventLog()))->importCampaignsFromCsv(
            $this->csv("id,targeting_exclude_states,targeting_ron\n4004,NV|OR,1\n")
        );

        $this->assertSame([['patch', 'campaigns/4004']], $api->calls);
        $this->assertArrayNotHasKey('exclude', $api->payloads[0]['targeting']);
        $this->assertSame('1', $api->payloads[0]['targeting']['ron'], 'the rest of the row still applies');
        $this->assertSame('targeting_exclude_states', $res[0]['ignored'][0]['column']);
        $this->assertStringContainsString('not stored for native campaigns', $res[0]['ignored'][0]['reason']);
    }

    public function testTheTypeNamedOnTheRowDecidesTheGuardOnACreate(): void
    {
        // A create has no stored type to read, so the guard has to trust the column.
        $api = new PayloadRecordingApiClient();
        (new ContextStubService($api, new SilentEventLog()))->importCampaignsFromCsv(
            $this->csv("name,budget,bid,type,targeting_exclude_states\nN,100,1,native,NV\n")
        );

        $this->assertSame([['post', 'campaigns']], $api->calls);
        $this->assertArrayNotHasKey('targeting', $api->payloads[0]);

        $api = new PayloadRecordingApiClient();
        (new ContextStubService($api, new SilentEventLog()))->importCampaignsFromCsv(
            $this->csv("name,budget,bid,type,targeting_exclude_states\nN,100,1,display,NV\n")
        );

        $this->assertSame(['NV'], $api->payloads[0]['targeting']['exclude']['states']);
    }

    public function testANativeRowWithNothingButExclusionsIsReportedNotSent(): void
    {
        // The shape QA uploaded: a native campaign id and one exclude column. With the
        // node withheld there is nothing left to send, so the row has to say why
        // rather than PATCH an empty payload or report a bare success.
        $api = new PayloadRecordingApiClient();
        $res = (new ContextStubService($api, new SilentEventLog()))->importCampaignsFromCsv(
            $this->csv("id,targeting_exclude_states\n4004,NV|OR\n")
        );

        $this->assertSame([], $api->calls);
        $this->assertStringContainsString('the only column with a value was set aside', $res[0]['message']);
        $this->assertStringContainsString('not stored for native campaigns', $res[0]['ignored'][0]['reason']);
    }

    // ---------------------------------------------------------------- frequency

    public function testAnOutOfRangeFrequencyStopsTheRowBeforeTheApi(): void
    {
        foreach (['-3' => 'is negative', '32768' => 'above the maximum', '2.5' => 'not a whole number'] as $value => $why) {
            $api = new PayloadRecordingApiClient();
            $res = (new ContextStubService($api, new SilentEventLog()))->importCampaignsFromCsv(
                $this->csv("id,targeting_frequency\n77," . $value . "\n")
            );

            $this->assertSame([], $api->calls, $value . ' must not reach the API');
            $this->assertStringContainsString($why, $res[0]['message']);
        }
    }

    public function testAFrequencyOfFiftyIsSentUnchanged(): void
    {
        // There is no 100 minimum on this column — 100 is the campaign form's default
        // for a new display campaign, which is not the same thing.
        $api = new PayloadRecordingApiClient();
        (new ContextStubService($api, new SilentEventLog()))->importCampaignsFromCsv(
            $this->csv("id,targeting_frequency\n77,50\n")
        );

        $this->assertSame([['patch', 'campaigns/77']], $api->calls);
        $this->assertSame('50', $api->payloads[0]['targeting']['frequency']);
    }
}

/**
 * Records the endpoint each call went to and always answers success, so the
 * assertions are about routing rather than API behaviour.
 */
class RecordingApiClient extends ApiClient
{
    /** @var array<int, array{0: string, 1: string}> */
    public $calls = [];

    public function __construct()
    {
        // Deliberately not calling parent::__construct() — no session or HTTP.
    }

    public function post($endpoint, array $options = [])
    {
        $this->calls[] = ['post', $endpoint];

        return ['status' => 201, 'body' => ['status' => true, 'id' => 1]];
    }

    public function patch($endpoint, array $options = [])
    {
        $this->calls[] = ['patch', $endpoint];

        return ['status' => 200, 'body' => ['status' => true]];
    }
}

/**
 * Answers a 200 whose message carries the markup the SA360 path emits.
 */
class MarkupMessageApiClient extends RecordingApiClient
{
    public function patch($endpoint, array $options = [])
    {
        parent::patch($endpoint, $options);

        return ['status' => 200, 'body' => [
            'status'  => true,
            'message' => 'Creative has been added<strong style="color:#f00;"> but SA360 sync failed</strong>',
        ]];
    }
}

/**
 * Supplies the two database-backed lookups from fixtures, so the ownership and
 * type-compatibility rules can be tested without a framework or a database.
 *
 * Advertiser 900 owns creative 55 (text) and 56 (video), and campaigns 4001
 * (text-search), 4002 (display) and 4003 (a combination with no campaign_type row).
 */
class ContextStubService extends ImportExportService
{
    protected function contextAdvertiserId(): int
    {
        return 900;
    }

    protected function storedGeoTargeting(int $campaignId, int $advId): ?array
    {
        if ($campaignId === 77) {
            return ['countries' => ['CAN', 'GBR'], 'metro' => ['501']];
        }
        // 78 already excludes a city and a ZIP, which is what a partial exclude row
        // used to wipe.
        if ($campaignId === 78) {
            return [
                'countries' => ['USA'],
                'exclude'   => [
                    'cities'    => ['los angeles:ca'],
                    'countries' => ['CAN'],
                    'zip'       => ['91001'],
                    'states'    => ['NV'],
                ],
            ];
        }
        // 4004 is the native campaign; its exclusions are the ones the API would
        // clear on any request carrying an exclude node.
        if ($campaignId === 4004) {
            return ['exclude' => ['states' => ['NV', 'OR']]];
        }

        return null;
    }

    protected function storedDisplayUrl(int $creativeId, int $advId): ?string
    {
        return $creativeId === 55 ? 'ShopNow.com' : null;
    }

    protected function storedCreativeTypes(int $advId): array
    {
        return [55 => 'text', 56 => 'video'];
    }

    protected function advertiserCampaignTypes(int $advId): array
    {
        return [4001 => 'text-search', 4002 => 'display', 4003 => '', 4004 => 'native', 77 => 'text-search', 78 => 'display'];
    }

    protected function storedCampaignDates(int $campaignId, int $advId): ?array
    {
        // 77 runs 2026-06-01 → 2026-06-30; 4001 has a start and no stop date.
        if ($campaignId === 77) {
            return ['start_date' => '2026-06-01', 'end_date' => '2026-06-30'];
        }
        if ($campaignId === 4001) {
            return ['start_date' => '2026-06-01', 'end_date' => null];
        }

        return null;
    }
}

/**
 * Keeps the JSON payload of every call, so a test can assert what was actually
 * sent rather than only which endpoint was hit.
 */
class PayloadRecordingApiClient extends RecordingApiClient
{
    /** @var array<int, array<string, mixed>> */
    public array $payloads = [];

    public function patch($endpoint, array $options = [])
    {
        $this->payloads[] = $options['json'] ?? [];

        return parent::patch($endpoint, $options);
    }

    public function post($endpoint, array $options = [])
    {
        $this->payloads[] = $options['json'] ?? [];

        return parent::post($endpoint, $options);
    }
}

class SilentEventLog extends EventLogService
{
    public function __construct()
    {
        // No DB.
    }

    public function add(array $data)
    {
        return true;
    }
}

/**
 * Answers every write with the API's validation envelope: HTTP 422, a
 * field-by-field `errors` list and no top-level `message`. That is what
 * in_list[A,S] produces for a status an advertiser may not set.
 */
class ValidationFailureApiClient extends RecordingApiClient
{
    public function post($endpoint, array $options = [])
    {
        $this->calls[] = ['post', $endpoint];

        return self::validationFailure();
    }

    public function patch($endpoint, array $options = [])
    {
        $this->calls[] = ['patch', $endpoint];

        return self::validationFailure();
    }

    private static function validationFailure(): array
    {
        return [
            'status' => 422,
            'body'   => [
                'status' => false,
                'errors' => [
                    ['field' => 'status', 'message' => 'The Status field must be one of: A,S.'],
                ],
            ],
        ];
    }
}

/**
 * Answers a create with whatever envelope the test hands it, so the row-message
 * assembly can be driven with shapes the live API does not currently produce.
 */
class StubCreateApiClient extends RecordingApiClient
{
    /** @var array */
    private $response;

    public function __construct(array $response)
    {
        parent::__construct();
        $this->response = $response;
    }

    public function post($endpoint, array $options = [])
    {
        $this->calls[] = ['post', $endpoint];

        return $this->response;
    }
}

/**
 * Answers 200 with BOTH a success message and an `errors` payload — the shape that
 * used to render a green line above a red list of problems.
 */
class SuccessWithErrorsApiClient extends RecordingApiClient
{
    public function patch($endpoint, array $options = [])
    {
        parent::patch($endpoint, $options);

        return [
            'status' => 200,
            'body'   => [
                'status'  => true,
                'message' => 'Campaign has been updated',
                'errors'  => [['field' => 'budget', 'message' => 'something the API muttered']],
            ],
        ];
    }
}
