<?php

namespace Tests\Unit\AdOps;

use App\Services\AdOps\CampaignCsv;
use PHPUnit\Framework\TestCase;

/**
 * Unit tests for CampaignCsv::decodeRow edge cases that were flagged in the
 * AP-29/AP-30 review. These are pure functions with no CI4/DB/HTTP dependency,
 * so they run without a framework bootstrap.
 */
final class CampaignCsvTest extends TestCase
{
    /**
     * The API mangles an out-of-range frequency rather than refusing it, and does it
     * silently in two directions: prepFrequency() runs abs(), so -3 becomes a live
     * cap of 3, and freq_cap is a smallint on a non-strict server, so 32768 is
     * clamped there while the targeting JSON keeps it whole. Refused on the row.
     */
    public function testNegativeFrequencyIsRefusedRatherThanTurnedPositive(): void
    {
        $errors = CampaignCsv::rowErrors(['name' => 'X', 'targeting_frequency' => '-3']);

        $this->assertCount(1, $errors);
        $this->assertStringContainsString('is negative', $errors[0]);
        $this->assertStringContainsString('Nothing on this row was changed', $errors[0]);
    }

    public function testFrequencyAboveTheColumnMaximumIsRefused(): void
    {
        $errors = CampaignCsv::rowErrors(['name' => 'X', 'targeting_frequency' => '32768']);

        $this->assertCount(1, $errors);
        $this->assertStringContainsString('above the maximum', $errors[0]);

        // The boundary itself is fine — it is the value freq_cap would have clamped to.
        $this->assertSame([], CampaignCsv::rowErrors(['name' => 'X', 'targeting_frequency' => '32767']));
    }

    public function testFractionalFrequencyIsRefused(): void
    {
        // freq_cap truncates it and the JSON keeps the decimal — the same split as
        // the overflow, from a value that is almost always a typo.
        $errors = CampaignCsv::rowErrors(['name' => 'X', 'targeting_frequency' => '1.5']);

        $this->assertCount(1, $errors);
        $this->assertStringContainsString('not a whole number', $errors[0]);
    }

    public function testFrequenciesInsideTheRangeAreAccepted(): void
    {
        // 50 included deliberately: there is no 100 minimum on this column, in this
        // app or in legacy. 0 is how an existing campaign's limit is removed, and a
        // blank cell leaves it alone — neither is an error.
        foreach (['1', '50', '100', '32767', '0', ''] as $value) {
            $this->assertSame(
                [],
                CampaignCsv::rowErrors(['name' => 'X', 'targeting_frequency' => $value]),
                'frequency ' . var_export($value, true) . ' must be accepted'
            );
        }
    }

    public function testNonNumericFrequencyIsLeftToTheIgnoredCellPath(): void
    {
        // Not a row error: decodeRow already withholds the cell and says so, which
        // keeps the stored cap and still applies the rest of the row. Refusing the
        // row as well would make one salvageable typo fatal.
        $this->assertSame([], CampaignCsv::rowErrors(['name' => 'X', 'targeting_frequency' => 'abc']));

        $payload = CampaignCsv::decodeRow(['id' => '77', 'targeting_frequency' => 'abc'], $ignored);
        $this->assertArrayNotHasKey('frequency', $payload['targeting'] ?? []);
        $this->assertSame('targeting_frequency', $ignored[0]['column']);
    }

    public function testEmptyPipeListColumnDoesNotEmitSingleBlankElement(): void
    {
        // Regression: explode('|', '') returned [''] and the API received
        // {"targeting":{"browsers":[""]}}, overwriting valid targeting.
        $row = ['name' => 'X', 'targeting_browsers' => ''];
        $payload = CampaignCsv::decodeRow($row);

        $this->assertArrayNotHasKey('browsers', $payload['targeting'] ?? []);
    }

    public function testPipeListColumnTrimsAndFiltersEmpties(): void
    {
        $row = ['name' => 'X', 'targeting_browsers' => 'chrome | safari||firefox|'];
        $payload = CampaignCsv::decodeRow($row);

        $this->assertSame(['chrome', 'safari', 'firefox'], $payload['targeting']['browsers']);
    }

    public function testEmptyTargetingScheduleProducesNoScheduleEntries(): void
    {
        // Regression: isset('') was true, so an empty schedule column pushed a
        // schedule entry with all-false fields into the PATCH payload.
        $row = ['name' => 'X', 'targeting_schedule' => ''];
        $payload = CampaignCsv::decodeRow($row);

        $this->assertArrayNotHasKey('schedule', $payload['targeting'] ?? []);
    }

    public function testValidScheduleEntryIsParsedCorrectly(): void
    {
        $row = ['name' => 'X', 'targeting_schedule' => 'mon 09:00-17:00|tue 10:00-18:00'];
        $payload = CampaignCsv::decodeRow($row);

        $this->assertSame(
            [
                ['day' => 'mon', 'start_time' => '09:00', 'end_time' => '17:00'],
                ['day' => 'tue', 'start_time' => '10:00', 'end_time' => '18:00'],
            ],
            $payload['targeting']['schedule']
        );
    }

    public function testMalformedScheduleEntryIsSkipped(): void
    {
        // Regression: the regex produced an all-false entry when it didn't match.
        $row = ['name' => 'X', 'targeting_schedule' => 'garbage|mon 09:00-17:00'];
        $payload = CampaignCsv::decodeRow($row);

        $this->assertCount(1, $payload['targeting']['schedule']);
        $this->assertSame('mon', $payload['targeting']['schedule'][0]['day']);
    }

    public function testLogicValueTokenWithEmptyValueIsSkipped(): void
    {
        // Regression: "include:" (logic-only) set categories.include = false,
        // which the API serialized as a literal boolean.
        $row = ['name' => 'X', 'targeting_categories' => 'include:'];
        $payload = CampaignCsv::decodeRow($row);

        $this->assertArrayNotHasKey('categories', $payload['targeting'] ?? []);
    }

    public function testLogicValueTokenWithEmptyLogicIsSkipped(): void
    {
        // ":value" would have set categories[''] — an empty-string key.
        $row = ['name' => 'X', 'targeting_categories' => ':1|2|3'];
        $payload = CampaignCsv::decodeRow($row);

        $this->assertArrayNotHasKey('categories', $payload['targeting'] ?? []);
    }

    public function testLogicValueTokenDoubleColonIsSkipped(): void
    {
        $row = ['name' => 'X', 'targeting_categories' => '::'];
        $payload = CampaignCsv::decodeRow($row);

        $this->assertArrayNotHasKey('categories', $payload['targeting'] ?? []);
    }

    public function testModeListSendsAFlatListPlusASeparateModeKey(): void
    {
        // Regression: this used to emit the nested {include: [...]} form, which
        // is how targeting is STORED but not what the API accepts. Sending it
        // made Targeting_model pass an array into DomainList_model::exists().
        $row = ['name' => 'X', 'targeting_categories' => 'include:1|2|3'];
        $payload = CampaignCsv::decodeRow($row);

        $this->assertSame(['1', '2', '3'], $payload['targeting']['categories']);
        $this->assertSame('include', $payload['targeting']['categories_mode']);
        $this->assertArrayNotHasKey('include', (array) $payload['targeting']['categories']);
    }

    public function testModeListWritesTheModeBeforeTheList(): void
    {
        // Targeting_model::prepare() dispatches prep* in payload key order, and
        // prepCategories reads the mode that prepCategories_mode captured, so
        // the mode key has to come first. Other targeting columns are included so
        // neither key sits at index 0 — otherwise a missing key would make
        // array_search return false and compare as less than 0, passing blindly.
        $payload = CampaignCsv::decodeRow([
            'name'                    => 'X',
            'targeting_countries'     => 'USA',
            'targeting_domain_lists'  => 'include:1|2',
            'targeting_categories'    => 'exclude:IAB2',
        ]);

        $keys = array_keys($payload['targeting']);
        foreach (['domain_lists', 'categories'] as $field) {
            $this->assertContains($field, $keys);
            $this->assertContains($field . '_mode', $keys);
            $this->assertGreaterThan(
                0,
                array_search($field . '_mode', $keys, true),
                $field . '_mode should not be the first key, or this assertion is blind'
            );
            $this->assertLessThan(
                array_search($field, $keys, true),
                array_search($field . '_mode', $keys, true),
                $field . '_mode must be sent before ' . $field
            );
        }
    }

    public function testBidExportDropsDecimalPaddingWithoutLosingPrecision(): void
    {
        // bid is decimal(10,6), so 0.05 comes back as "0.050000" and the export
        // carried the padding into the CSV. Sub-cent bids are legitimate — there
        // are live campaigns down to 0.000001 and per-advertiser floors of 0.0001
        // — so this must not round, and must not emit scientific notation.
        $cases = [
            '0.050000'  => '0.05',
            '0.001000'  => '0.001',
            '0.000100'  => '0.0001',
            '0.000001'  => '0.000001',
            '12.500000' => '12.5',
            '1.000000'  => '1',
            '0.000000'  => '0',
            '0'         => '0',
        ];

        foreach ($cases as $stored => $expected) {
            $row = CampaignCsv::encodeRow((object) ['bid' => $stored], null);
            $this->assertSame($expected, $row['bid'], 'bid ' . $stored . ' exported wrong');
            $this->assertStringNotContainsStringIgnoringCase('e', (string) $row['bid']);
        }
    }

    public function testBidExportLeavesBlankAndNonNumericValuesAlone(): void
    {
        // 'N.A.' is the case with teeth: it contains a dot, so only the is_numeric
        // guard stops it being trimmed to 'N.A'.
        foreach (['' => '', 'abc' => 'abc', 'N.A.' => 'N.A.', 'up to 1.50' => 'up to 1.50'] as $stored => $expected) {
            $row = CampaignCsv::encodeRow((object) ['bid' => $stored], null);
            $this->assertSame($expected, $row['bid'], var_export($stored, true) . ' must pass through untouched');
        }

        // Absent entirely — must not fatal on the string cast.
        $row = CampaignCsv::encodeRow((object) [], null);
        $this->assertSame('', $row['bid']);
    }

    public function testBidExportExpandsScientificNotationRatherThanEmittingIt(): void
    {
        // A float below 1e-5 stringifies as "1.0E-6", and PHP hands floats straight
        // through encodeRow's json round trip. An exponent in a CSV cell is not
        // something the importer or a spreadsheet should have to deal with.
        $cases = [
            '1.0E-6'  => '0.000001',
            '1e-6'    => '0.000001',
            '1.00E+2' => '100',
        ];

        foreach ($cases as $stored => $expected) {
            $row = CampaignCsv::encodeRow((object) ['bid' => $stored], null);
            $this->assertSame($expected, $row['bid'], 'bid ' . $stored . ' should expand');
        }

        // The same value arriving as a genuine float, not a string.
        $row = CampaignCsv::encodeRow((object) ['bid' => 0.000001], null);
        $this->assertSame('0.000001', $row['bid']);
        $this->assertStringNotContainsStringIgnoringCase('e', $row['bid']);
    }

    public function testRpcColumnsGetTheSameDecimalTreatmentAsBid(): void
    {
        // These used `$v * 1`, the arithmetic trimDecimal() exists to avoid.
        $row = CampaignCsv::encodeRow(
            (object) ['click_cap_rpc' => '0.200000', 'click_drop_rpc' => '0.000001'],
            null
        );

        $this->assertSame('0.2', $row['click_cap_rpc']);
        $this->assertSame('0.000001', $row['click_drop_rpc']);
    }

    public function testRequiredForCreateMatchesTheApiRequiredRules(): void
    {
        // These four carry `required` in the API's campaign rule set and are the
        // four the field guide marks Required for creating. Presence-only: the
        // value rules stay server-side.
        $this->assertSame(['name', 'budget', 'bid', 'type'], CampaignCsv::REQUIRED_FOR_CREATE);
    }

    public function testCampaignRowSurvivesAnExportReimportRoundTrip(): void
    {
        // encodeRow still writes the STORED nested form as "mode:a|b" cells, so
        // export -> re-import has to land back on the flat+mode payload. This is
        // also the check that every canonical vocabulary token the exporter emits
        // is one the decoder still recognises.
        $campaign = (object) [
            'id'        => 42,
            'name'      => 'Round Trip',
            'type'      => 'display',
            'targeting' => (object) [
                'browsers'         => ['chrome', 'msie'],
                'os'               => ['windows', 'macos'],
                'connection_types' => ['wifi', 'ethernet'],
                'devices'          => ['desktop', 'phone'],
                'categories'       => (object) ['include' => ['IAB2', 'IAB2-1']],
                'domain_lists'     => (object) ['exclude' => ['7', '9']],
                'countries'        => ['USA'],
            ],
        ];

        $row     = CampaignCsv::encodeRow($campaign, null);
        $payload = CampaignCsv::decodeRow($row);
        $t       = $payload['targeting'];

        $this->assertSame(['chrome', 'msie'], $t['browsers']);
        $this->assertSame(['windows', 'macos'], $t['os']);
        $this->assertSame(['wifi', 'ethernet'], $t['connection_types']);
        $this->assertSame(['desktop', 'phone'], $t['devices']);
        $this->assertSame(['IAB2', 'IAB2-1'], $t['categories']);
        $this->assertSame('include', $t['categories_mode']);
        $this->assertSame(['7', '9'], $t['domain_lists']);
        $this->assertSame('exclude', $t['domain_lists_mode']);
    }

    public function testDomainListsDropsNonNumericIdsAndUnknownModes(): void
    {
        // "WHERE list_id = Array" / non-numeric ids are what produced the 500.
        $this->assertArrayNotHasKey(
            'domain_lists',
            CampaignCsv::decodeRow(['name' => 'X', 'targeting_domain_lists' => 'include:abc'])['targeting'] ?? []
        );
        $this->assertArrayNotHasKey(
            'domain_lists',
            CampaignCsv::decodeRow(['name' => 'X', 'targeting_domain_lists' => 'bogus:1'])['targeting'] ?? []
        );

        // Commas are tolerated here because the guide told users to use them.
        $payload = CampaignCsv::decodeRow(['name' => 'X', 'targeting_domain_lists' => 'include:1,2']);
        $this->assertSame(['1', '2'], $payload['targeting']['domain_lists']);
    }

    public function testRetargetingBothHalvesRequiredToEmit(): void
    {
        foreach ([':', 'include:', ':1|2', ''] as $bad) {
            $payload = CampaignCsv::decodeRow(['name' => 'X', 'targeting_retargeting' => $bad]);
            $this->assertArrayNotHasKey(
                'retargeting',
                $payload['targeting'] ?? [],
                'Expected retargeting to be absent for input: ' . var_export($bad, true)
            );
        }
    }

    public function testExcludePipeListEmptyValueDoesNotEmit(): void
    {
        $row = ['name' => 'X', 'targeting_exclude_states' => ''];
        $payload = CampaignCsv::decodeRow($row);

        $this->assertArrayNotHasKey('exclude', $payload['targeting'] ?? []);
    }

    /**
     * The API whitelists devices case-sensitively and unsets the key when
     * nothing matches, which the campaign form renders as "All Devices".
     * Anything the exporter writes, the field guide documents, or Campaign
     * Manager labels must therefore normalize onto that vocabulary.
     */
    public function testDeviceCellsNormalizeOntoApiVocabulary(): void
    {
        $cases = [
            'Mobile and Desktop'          => ['phone', 'desktop'],
            'desktop|phone'               => ['desktop', 'phone'],
            'desktop,phone'               => ['desktop', 'phone'],
            'Desktop|Phone'               => ['desktop', 'phone'],
            'Desktops & Laptops|Phones'   => ['desktop', 'phone'],
            'Tablets and Phones'          => ['tablet', 'phone'],
            'desktop|tablet|phone|others' => ['desktop', 'tablet', 'phone', 'others'],
        ];

        foreach ($cases as $cell => $expected) {
            $payload = CampaignCsv::decodeRow(['name' => 'X', 'targeting_devices' => $cell]);
            $this->assertSame(
                $expected,
                $payload['targeting']['devices'] ?? null,
                'Unexpected devices for input: ' . var_export($cell, true)
            );
        }
    }

    /**
     * Targeting_model intersects browsers/os/connection_types against
     * all-lowercase vocabularies, case-sensitively, and unsets the key when
     * nothing matches — which the campaign form renders as "All <field>". A cell
     * reading "Chrome", "Windows" or "WiFi" was therefore silently discarded.
     */
    public function testClosedVocabularyColumnsNormalizeCaseAndLabels(): void
    {
        $cases = [
            ['targeting_browsers', 'Chrome', 'browsers', ['chrome']],
            ['targeting_browsers', 'Chrome|Firefox', 'browsers', ['chrome', 'firefox']],
            ['targeting_browsers', 'Chrome,Safari', 'browsers', ['chrome', 'safari']],
            ['targeting_browsers', 'IE/Edge', 'browsers', ['msie']],
            ['targeting_browsers', 'Internet Explorer', 'browsers', ['msie']],
            ['targeting_os', 'Windows', 'os', ['windows']],
            ['targeting_os', 'Mac OS|iOS', 'os', ['macos', 'ios']],
            ['targeting_os', 'Chrome OS', 'os', ['chromeos']],
            ['targeting_connection_types', 'WiFi', 'connection_types', ['wifi']],
            ['targeting_connection_types', 'Wi-Fi, Ethernet', 'connection_types', ['wifi', 'ethernet']],
            ['targeting_connection_types', 'Cellular', 'connection_types', ['cellular']],
            ['targeting_devices', 'desktop|phone', 'devices', ['desktop', 'phone']],
            ['targeting_devices', 'Mobile and Desktop', 'devices', ['phone', 'desktop']],
        ];

        foreach ($cases as [$column, $cell, $field, $expected]) {
            $payload = CampaignCsv::decodeRow(['name' => 'X', $column => $cell]);
            $this->assertSame(
                $expected,
                $payload['targeting'][$field] ?? null,
                'Unexpected ' . $field . ' for input: ' . var_export($cell, true)
            );
        }
    }

    /**
     * The API's vocabularies, verbatim from Targeting_model. Every one of these
     * tokens is what the EXPORTER writes, so each must survive a re-import
     * unchanged — a canonical token missing from the synonym map would be
     * dropped and the form would render "All <field>".
     */
    private const API_VOCABULARIES = [
        'targeting_browsers'         => ['browsers', ['chrome', 'firefox', 'safari', 'opera', 'msie', 'unknown']],
        'targeting_os'               => ['os', ['windows', 'macos', 'android', 'ios', 'linux', 'blackberry', 'chromeos', 'others']],
        'targeting_connection_types' => ['connection_types', ['ethernet', 'wifi', 'cellular', 'unknown']],
        'targeting_devices'          => ['devices', ['desktop', 'tablet', 'phone', 'others']],
    ];

    public function testEveryCanonicalVocabularyTokenSurvivesUnchanged(): void
    {
        foreach (self::API_VOCABULARIES as $column => [$field, $allowed]) {
            foreach ($allowed as $token) {
                $payload = CampaignCsv::decodeRow(['name' => 'X', $column => $token]);
                $this->assertSame(
                    [$token],
                    $payload['targeting'][$field] ?? null,
                    'Canonical token "' . $token . '" must survive ' . $column
                );
            }

            // And all of them together, in one cell.
            $payload = CampaignCsv::decodeRow(['name' => 'X', $column => implode('|', $allowed)]);
            $this->assertSame($allowed, $payload['targeting'][$field] ?? null, $column . ' lost a token');
        }
    }

    public function testClosedVocabularyColumnsEmitOnlyCanonicalLowercaseTokens(): void
    {
        // Anything not resolvable is dropped rather than passed through, because
        // one unknown token makes the API discard the whole field.
        foreach (self::API_VOCABULARIES as $column => [$field, $allowed]) {
            $payload = CampaignCsv::decodeRow(['name' => 'X', $column => 'junkvalue|Chrome|Windows|WiFi|desktop']);

            // Guard against the assertion loop being skipped entirely: if the
            // field were absent this test would otherwise pass vacuously.
            $this->assertNotEmpty(
                $payload['targeting'][$field] ?? [],
                $column . ' dropped every token, so the vocabulary check never ran'
            );
            foreach ($payload['targeting'][$field] as $token) {
                $this->assertContains($token, $allowed, $column . ' emitted a token outside the API vocabulary');
            }
        }
    }

    public function testAnExplicitAllCellClearsTheRestriction(): void
    {
        // Regression: normalizing simply dropped unresolvable tokens, so "All
        // Browsers" omitted the key — and because Targeting_model::update() seeds
        // itself from stored targeting and only dispatches prep* for keys the
        // payload carries, the old restriction survived. There was then no way to
        // widen a campaign back to "all" from a CSV. Sending an empty list gets
        // prep* dispatched, whose array_intersect unsets the stored value.
        foreach (self::API_VOCABULARIES as $column => [$field, $allowed]) {
            foreach (['all', 'ALL', 'all browsers', 'All Devices', 'All OS'] as $cell) {
                $payload = CampaignCsv::decodeRow(['name' => 'X', $column => $cell]);
                $this->assertSame(
                    [],
                    $payload['targeting'][$field] ?? null,
                    $column . ' = ' . var_export($cell, true) . ' must send an empty list to clear'
                );
            }
        }
    }

    public function testBlankOrUnrecognisedVocabCellsLeaveTargetingAlone(): void
    {
        // A blank cell means "don't touch". A typo must not wipe live targeting
        // either — omitting the key preserves whatever is stored.
        foreach (self::API_VOCABULARIES as $column => [$field, $allowed]) {
            foreach (['', '   ', 'junkvalue', 'Brave', 'Windows 95'] as $cell) {
                $payload = CampaignCsv::decodeRow(['name' => 'X', $column => $cell]);
                $this->assertArrayNotHasKey(
                    $field,
                    $payload['targeting'] ?? [],
                    'Expected ' . $field . ' absent for ' . $column . ' = ' . var_export($cell, true)
                );
            }
        }
    }

    /**
     * Every column the CSV template offers, in order. Hardcoded on purpose: an
     * assertion against array_keys(encodeRow(...)) would just be templateHeader()
     * compared to its own implementation and could not fail. This golden list
     * pins additions, removals and reordering.
     */
    private const TEMPLATE_COLUMNS = [
        'id', 'name', 'budget', 'bid', 'type', 'mode', 'status', 'start_date', 'end_date',
        'creatives',
        'targeting_browsers', 'targeting_brand_type', 'targeting_brand_type_list',
        'targeting_categories', 'targeting_channel',
        'targeting_cities', 'targeting_exclude_cities',
        'targeting_connection_types',
        'targeting_countries', 'targeting_exclude_countries',
        'targeting_devices', 'targeting_domain_lists', 'targeting_frequency',
        'targeting_keyword_lists_search', 'targeting_keyword_lists_negative',
        'targeting_metro', 'targeting_exclude_metro',
        'targeting_os', 'targeting_pacing', 'targeting_retargeting', 'targeting_ron',
        'targeting_source_lists',
        'targeting_states', 'targeting_exclude_states',
        'targeting_supplies', 'targeting_schedule',
        'targeting_zip', 'targeting_exclude_zip',
        'publisher_ids', 'click_cap', 'click_cap_rpc', 'click_drop_rpc', 'conversion_cap',
    ];

    public function testTemplateHeaderMatchesTheGoldenColumnList(): void
    {
        $this->assertSame(self::TEMPLATE_COLUMNS, CampaignCsv::templateHeader());
    }

    public function testTemplateHeaderContainsThePreviouslyMissingColumns(): void
    {
        $header = CampaignCsv::templateHeader();

        $missing = [
            'targeting_brand_type',
            'targeting_brand_type_list',
            'targeting_exclude_cities',
            'targeting_exclude_countries',
            'targeting_exclude_metro',
            'targeting_exclude_states',
            'targeting_exclude_zip',
        ];
        foreach ($missing as $column) {
            $this->assertContains($column, $header, $column . ' must be offered in the CSV template');
        }
    }

    public function testTemplateHeaderOmitsReadOnlyRollupColumns(): void
    {
        // total_* are export-only stats; offering them in the template would
        // invite users to fill in values the importer silently drops. Assert the
        // positive branch too, otherwise this passes for the wrong reason if the
        // HEARST_ADV_IDS gate is ever unreachable (it was, before the bootstrap
        // started defining the constant).
        $rollups = ['total_impressions', 'total_clicks', 'total_budget'];

        $hearstExport = array_keys(CampaignCsv::encodeRow((object) [], 13421));
        foreach ($rollups as $column) {
            $this->assertContains($column, $hearstExport, 'Hearst exports must still carry ' . $column);
        }

        $header = CampaignCsv::templateHeader();
        foreach ($rollups as $column) {
            $this->assertNotContains($column, $header);
        }
    }

    public function testFieldsDescriptionTableDocumentsEveryTemplateColumn(): void
    {
        // The user-visible defect was a template column with no field-guide row
        // (and vice versa). Pin both directions so the docs cannot drift again.
        $view = file_get_contents(
            dirname(__DIR__, 3) . '/app/Views/import-export/partials/fields_campaigns.php'
        );
        $this->assertNotFalse($view);

        preg_match_all('#<td><code>([a-z_]+)</code></td>#', $view, $matches);
        $documented = $matches[1];

        $this->assertSame(
            [],
            array_values(array_diff(CampaignCsv::templateHeader(), $documented)),
            'Template columns with no row in the fields description table'
        );
        $this->assertSame(
            [],
            array_values(array_diff($documented, CampaignCsv::templateHeader())),
            'Documented fields that the template does not offer'
        );
        $this->assertSame($documented, array_unique($documented), 'Duplicate field rows');
    }

    public function testTemplateDownloadDerivesItsHeaderFromTheService(): void
    {
        // The original bug was a hand-maintained column list in the view drifting
        // behind the exporter. Nothing else in the suite covers the view, so this
        // pins that the literal has not come back.
        $view = file_get_contents(
            dirname(__DIR__, 3) . '/app/Views/import-export/partials/tab_campaigns.php'
        );
        $this->assertNotFalse($view);

        $this->assertStringContainsString('CampaignCsv::templateHeader()', $view);
        $this->assertStringNotContainsString(
            'id,name,budget,bid,type',
            $view,
            'The CSV template header must not be hardcoded in the view'
        );
    }

    public function testFieldsDescriptionTableDocumentsThePipeDelimiter(): void
    {
        // decodeRow() splits list cells on "|", never ",". The table used to say
        // "separated by commas" with comma examples, which silently produced
        // single-token targeting that the API then discarded.
        $view = file_get_contents(
            dirname(__DIR__, 3) . '/app/Views/import-export/partials/fields_campaigns.php'
        );

        $this->assertStringNotContainsString('separated by commas', $view);
        $this->assertSame(
            [],
            self::commaSeparatedExamples($view),
            'Multi-value examples must use the pipe delimiter the parser splits on'
        );
    }

    /**
     * Examples that list several values with commas, excluding the legitimately
     * comma-free ones (dates, single scalars).
     *
     * @return string[]
     */
    private static function commaSeparatedExamples(string $view): array
    {
        preg_match_all('#Example: <code>([^<]*)</code>#', $view, $matches);

        return array_values(array_filter($matches[1], static function (string $example): bool {
            // A date like 2026-04-05 has no comma; anything with one is a list.
            return strpos($example, ',') !== false;
        }));
    }

    /**
     * Where each template column must land in the API payload, plus a
     * format-correct sample cell. The "logic:value", schedule and device cells
     * are rejected by design when they don't parse, so a generic sentinel would
     * produce false failures.
     *
     * `id` and `mode` are deliberately absent: `id` is consumed by the caller to
     * pick POST vs PATCH, and `mode` is derived server-side from `type`.
     *
     * @return array<string, array{0: string, 1: string[]}>
     */
    private static function columnPayloadPaths(): array
    {
        return [
            'name'                             => ['X2', ['name']],
            'budget'                           => ['5', ['budget']],
            'bid'                              => ['0.5', ['bid']],
            'type'                             => ['display', ['type']],
            'status'                           => ['S', ['status']],
            'start_date'                       => ['2026-04-05', ['start_date']],
            'end_date'                         => ['2026-05-03', ['end_date']],
            'creatives'                        => ['1', ['creatives']],
            'targeting_browsers'               => ['chrome', ['targeting', 'browsers']],
            'targeting_brand_type'             => ['1', ['targeting', 'brand_type']],
            'targeting_brand_type_list'        => ['1', ['targeting', 'brand_type_list']],
            'targeting_categories'             => ['include:IAB2', ['targeting', 'categories']],
            'targeting_channel'                => ['1', ['targeting', 'channel']],
            'targeting_cities'                 => ['los angeles:ca', ['targeting', 'cities']],
            'targeting_exclude_cities'         => ['los angeles:ca', ['targeting', 'exclude', 'cities']],
            'targeting_connection_types'       => ['wifi', ['targeting', 'connection_types']],
            'targeting_countries'              => ['USA', ['targeting', 'countries']],
            'targeting_exclude_countries'      => ['CAN', ['targeting', 'exclude', 'countries']],
            'targeting_devices'                => ['desktop', ['targeting', 'devices']],
            'targeting_domain_lists'           => ['include:1', ['targeting', 'domain_lists']],
            'targeting_frequency'              => ['100', ['targeting', 'frequency']],
            'targeting_keyword_lists_search'   => ['1', ['targeting', 'keyword_lists', 'include']],
            'targeting_keyword_lists_negative' => ['1', ['targeting', 'keyword_lists', 'exclude']],
            'targeting_metro'                  => ['501', ['targeting', 'metro']],
            'targeting_exclude_metro'          => ['501', ['targeting', 'exclude', 'metro']],
            'targeting_os'                     => ['windows', ['targeting', 'os']],
            'targeting_pacing'                 => ['1', ['targeting', 'pacing']],
            'targeting_retargeting'            => ['or:1', ['targeting', 'retargeting', 'ids']],
            'targeting_ron'                    => ['1', ['targeting', 'ron']],
            'targeting_source_lists'           => ['1', ['targeting', 'source_lists']],
            'targeting_states'                 => ['AZ', ['targeting', 'states']],
            'targeting_exclude_states'         => ['NV', ['targeting', 'exclude', 'states']],
            'targeting_supplies'               => ['web', ['targeting', 'supplies']],
            'targeting_schedule'               => ['monday 10:00-21:00', ['targeting', 'schedule']],
            'targeting_zip'                    => ['91001', ['targeting', 'zip']],
            'targeting_exclude_zip'            => ['91001', ['targeting', 'exclude', 'zip']],
            'publisher_ids'                    => ['55', ['targeting', 'affiliates']],
            'click_cap'                        => ['20000', ['affiliate_info', 'click_cap']],
            'click_cap_rpc'                    => ['0.2', ['affiliate_info', 'click_cap_rpc']],
            'click_drop_rpc'                   => ['0.01', ['affiliate_info', 'click_drop_rpc']],
            'conversion_cap'                   => ['50', ['affiliate_info', 'conversion_cap']],
        ];
    }

    public function testEveryTemplateColumnIsEitherParsedOrKnownExportOnly(): void
    {
        // Guards against the template offering a column the parser drops on the
        // floor, and against one that parses into the wrong payload key.
        $exportOnly = ['id', 'mode'];
        $paths      = self::columnPayloadPaths();

        $expected = array_values(array_diff(CampaignCsv::templateHeader(), $exportOnly));
        $this->assertSame(
            $expected,
            array_keys($paths),
            'columnPayloadPaths() must cover every non-export-only template column'
        );

        foreach ($paths as $column => [$sample, $path]) {
            // The single-publisher RPC caps are only applied when exactly one
            // publisher id is present, so they need that context in the row.
            $row = $path[0] === 'affiliate_info'
                ? ['name' => 'X', 'publisher_ids' => '55']
                : ['name' => 'X'];
            $row[$column] = $sample;

            $node  = CampaignCsv::decodeRow($row);
            $trail = '';
            foreach ($path as $segment) {
                $trail .= '[' . $segment . ']';
                $this->assertIsArray($node, 'Column "' . $column . '": expected an array at ' . $trail);
                $this->assertArrayHasKey(
                    $segment,
                    $node,
                    'Column "' . $column . '" must reach payload path ' . $trail
                );
                $node = $node[$segment];
            }
            $this->assertNotSame([], $node, 'Column "' . $column . '" landed at ' . $trail . ' but is empty');
        }
    }

    public function testExportOnlyColumnsNeverReachThePayload(): void
    {
        // The inverse of the check above: nothing should smuggle `mode` through.
        $payload = CampaignCsv::decodeRow(array_fill_keys(CampaignCsv::templateHeader(), ''));

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

    public function testAnAllBlankRowSendsNothingButTheStatusDefault(): void
    {
        // Every export carries all 43 columns, so a campaign with no publishers,
        // no creatives and no frequency exports blank cells for them. Re-importing
        // that row must be a no-op — it previously sent affiliates: [''] together
        // with xml_traffic: 1, converting an ordinary campaign to XML traffic with
        // a blank publisher, plus [''] creatives and a zeroed frequency cap.
        $payload = CampaignCsv::decodeRow(array_fill_keys(CampaignCsv::templateHeader(), ''));

        foreach (['xml_traffic', 'affiliate_info', 'creatives'] as $key) {
            $this->assertArrayNotHasKey($key, $payload, $key . ' must not be sent for a blank row');
        }
        $this->assertArrayNotHasKey('affiliates', $payload['targeting'] ?? []);
        foreach (['frequency', 'pacing', 'ron', 'brand_type'] as $key) {
            $this->assertArrayNotHasKey($key, $payload['targeting'] ?? [], $key . ' must not be cleared by a blank cell');
        }

        // No targeting key at all survives an all-blank row.
        $this->assertArrayNotHasKey('targeting', $payload);

        // What DOES still go out: `status` (its documented default) and the
        // top-level scalars, which decodeRow sends as '' when the column is
        // present. Harmless on create — the required-column pre-check rejects the
        // row before it is sent — and left alone here rather than changing update
        // semantics for dates, where a blank cell plausibly means "clear it".
        $this->assertSame('A', $payload['status']);
        $this->assertSame(
            ['name', 'budget', 'bid', 'type', 'status', 'start_date', 'end_date'],
            array_keys($payload)
        );
    }

    public function testBlankScalarTargetingCellsDoNotClearStoredValues(): void
    {
        $payload = CampaignCsv::decodeRow([
            'name'                 => 'X',
            'targeting_frequency'  => '',
            'targeting_pacing'     => '  ',
            'targeting_ron'        => '',
            'targeting_brand_type' => '',
            'creatives'            => '',
            'publisher_ids'        => '',
        ]);

        $this->assertSame(['name' => 'X'], $payload);
    }

    public function testPopulatedPublisherIdsStillEnableXmlTrafficAndCaps(): void
    {
        $payload = CampaignCsv::decodeRow([
            'name'          => 'X',
            'publisher_ids' => '55',
            'click_cap'     => '20000',
        ]);

        $this->assertSame(['55'], $payload['targeting']['affiliates']);
        $this->assertSame(1, $payload['xml_traffic']);
        $this->assertSame('20000', $payload['affiliate_info']['click_cap']);
    }

    public function testPreviouslyMissingTemplateColumnsReachThePayload(): void
    {
        $payload = CampaignCsv::decodeRow([
            'name'                        => 'X',
            'targeting_brand_type'        => '1',
            'targeting_brand_type_list'   => '1234|3456',
            'targeting_exclude_cities'    => 'los angeles:ca',
            'targeting_exclude_countries' => 'CAN',
            'targeting_exclude_metro'     => '501',
            'targeting_exclude_states'    => 'NV',
            'targeting_exclude_zip'       => '91001',
        ]);

        $this->assertSame('1', $payload['targeting']['brand_type']);
        $this->assertSame(['1234', '3456'], $payload['targeting']['brand_type_list']);
        $this->assertSame(['los angeles:ca'], $payload['targeting']['exclude']['cities']);
        $this->assertSame(['CAN'], $payload['targeting']['exclude']['countries']);
        $this->assertSame(['501'], $payload['targeting']['exclude']['metro']);
        $this->assertSame(['NV'], $payload['targeting']['exclude']['states']);
        $this->assertSame(['91001'], $payload['targeting']['exclude']['zip']);
    }

    public function testModeColumnIsIgnoredOnImport(): void
    {
        // `mode` ships in the template only so an exported file re-imports
        // unchanged; the API derives campaign_mode from `type`.
        $payload = CampaignCsv::decodeRow(['name' => 'X', 'type' => 'display', 'mode' => 'CPC']);

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

    public function testChannelIsSentAsAScalarNotAList(): void
    {
        // prepChannel() runs abs() over it, and abs() of an array is false, which
        // unsets the channel. Storage holds a scalar: {"channel":71,...}.
        $out = CampaignCsv::decodeRow(['name' => 'QA', 'targeting_channel' => '71']);

        $this->assertSame('71', $out['targeting']['channel']);
        $this->assertIsNotArray($out['targeting']['channel']);
    }

    public function testAnUntouchedChannelSurvivesTheRoundTrip(): void
    {
        // Export then re-import unchanged used to wipe the channel of every
        // text-search, shopping and adx campaign.
        $exported = CampaignCsv::encodeRow((object) [
            'name'      => 'QA',
            'targeting' => (object) ['channel' => 71],
        ], null);

        $this->assertSame('71', $exported['targeting_channel']);
        $this->assertSame('71', CampaignCsv::decodeRow($exported)['targeting']['channel']);
    }

    public function testABlankChannelLeavesTheStoredValueAlone(): void
    {
        $out = CampaignCsv::decodeRow(['name' => 'QA', 'targeting_channel' => '']);

        $this->assertArrayNotHasKey('channel', $out['targeting'] ?? []);
    }

    public function testAPipedChannelIsRefusedRatherThanTruncated(): void
    {
        // abs("71|72") is 71, so sending it would keep the first id, drop the
        // rest and still report the row a success.
        $out = CampaignCsv::decodeRow(['name' => 'QA', 'targeting_channel' => '71|72']);

        $this->assertArrayNotHasKey('channel', $out['targeting'] ?? []);
    }

    public function testANonNumericChannelIsRefused(): void
    {
        $out = CampaignCsv::decodeRow(['name' => 'QA', 'targeting_channel' => 'search']);

        $this->assertArrayNotHasKey('channel', $out['targeting'] ?? []);
    }

    public function testANonNumericFrequencyIsRefusedRatherThanDisarmingTheCap(): void
    {
        // abs("abc") is 0, which takes prepFrequency's unset branch — so a typo
        // used to clear a live frequency cap and report the row a success.
        $out = CampaignCsv::decodeRow(['name' => 'QA', 'targeting_frequency' => 'abc']);

        $this->assertArrayNotHasKey('frequency', $out['targeting'] ?? []);
    }

    public function testAPartlyNumericFrequencyIsRefused(): void
    {
        // abs("50abc") is 50, which would silently accept a value nobody typed.
        $out = CampaignCsv::decodeRow(['name' => 'QA', 'targeting_frequency' => '50abc']);

        $this->assertArrayNotHasKey('frequency', $out['targeting'] ?? []);
    }

    public function testAValidFrequencyBelowTheDocumentedDefaultIsStillSent(): void
    {
        // 50 is a value the campaign form produces and 17 display/CPM rows hold.
        $out = CampaignCsv::decodeRow(['name' => 'QA', 'targeting_frequency' => '50']);

        $this->assertSame('50', $out['targeting']['frequency']);
    }

    // ─────────────────────────────── creatives column

    public function testACommaSeparatedCreativesCellKeepsEveryId(): void
    {
        // explode('|') left this as the single token "250,251", which the API looked
        // up as WHERE id = '250,251' and a non-strict server coerced to 250 — so the
        // row reported success with only the first creative linked.
        $payload = CampaignCsv::decodeRow(['name' => 'QA', 'creatives' => '250,251']);

        $this->assertSame(['250', '251'], $payload['creatives']);
    }

    public function testAPipeSeparatedCreativesCellStillWorks(): void
    {
        $payload = CampaignCsv::decodeRow(['name' => 'QA', 'creatives' => '250|251']);

        $this->assertSame(['250', '251'], $payload['creatives']);
    }

    public function testACreativesCellHoldingSomethingOtherThanIdsIsWithheldWhole(): void
    {
        // The numeric subset is never sent: linkCreatives replaces the campaign's
        // links with whatever it receives, so half a list unlinks the rest.
        $ignored = null;
        $payload = CampaignCsv::decodeRow(['id' => '7', 'creatives' => '250,251x'], $ignored);

        $this->assertArrayNotHasKey('creatives', $payload);
        $this->assertStringContainsString('251x', $ignored[0]['reason']);
        $this->assertStringContainsString('whole number', $ignored[0]['reason']);
    }

    public function testACreativesCellThatIsOnlySeparatorsIsReported(): void
    {
        $ignored = $this->ignoredFor(['name' => 'QA', 'creatives' => '|,|']);

        $this->assertStringContainsString('no creative ids found', $ignored['creatives']);
    }

    public function testARepeatedCreativeIdIsSentOnlyOnce(): void
    {
        // campaign_creative_link's primary key is (campaign_id, creative_id) and
        // linkCreatives builds one multi-row insert, so the repeat made the whole
        // statement fail with MySQL 1062 and the API answer HTTP 500.
        $payload = CampaignCsv::decodeRow(['name' => 'QA', 'creatives' => '250|250|251']);

        $this->assertSame(['250', '251'], $payload['creatives']);
    }

    public function testTheExportDoesNotRepeatACreativeId(): void
    {
        // Campaign_model::get() GROUP_CONCATs over a join set its own campaign_type
        // join can multiply, so one link comes back as [184383, 184383]. Writing
        // that out made an untouched re-import fail on a duplicate-key insert.
        $row = CampaignCsv::encodeRow((object) ['creatives' => [184383, 184383]]);

        $this->assertSame('184383', $row['creatives']);
    }

    // ─────────────────────────────── sources vs publishers

    public function testASourcesSelectionExportsUnderTargetingSourceLists(): void
    {
        // The invariant the campaign editor has to honour. It used to write the
        // Sources multi-select into targeting.affiliates, so the value landed in
        // publisher_ids — where the exporter reads PUBLISHERS — and
        // targeting_source_lists came out empty for every campaign edited in this app.
        // That is QA's "Source is present in edit campaign view but not in the
        // downloaded file".
        $row = CampaignCsv::encodeRow((object) ['targeting' => (object) ['source_lists' => [57]]]);

        $this->assertSame('57', $row['targeting_source_lists']);
        $this->assertSame('', $row['publisher_ids']);
    }

    public function testAPublisherListExportsUnderPublisherIdsAndNotAsASource(): void
    {
        // The other half: affiliates are publisher ids, and their presence is what
        // marks a campaign as XML traffic on re-import. The two columns are not
        // interchangeable.
        $row = CampaignCsv::encodeRow((object) ['targeting' => (object) ['affiliates' => [118509]]]);

        $this->assertSame('118509', $row['publisher_ids']);
        $this->assertSame('', $row['targeting_source_lists']);
    }

    // ─────────────────────────────── states

    public function testStateNamesAreConvertedToCodes(): void
    {
        // prepStates keeps only ^[A-Z0-9]{2,3}$, so names were dropped — and
        // because the key was still in the payload it ran and cleared the stored
        // states too. Reproduced on dev: "California|Texas" left no states at all.
        $out = CampaignCsv::decodeRow(['id' => '7', 'targeting_states' => 'California|Texas']);

        $this->assertSame(['CA', 'TX'], $out['targeting']['states']);
    }

    public function testStateCodesStillPassThroughUppercased(): void
    {
        $out = CampaignCsv::decodeRow(['id' => '7', 'targeting_states' => 'ca|tx']);

        $this->assertSame(['CA', 'TX'], $out['targeting']['states']);
    }

    public function testANonUsSubdivisionCodeIsNotTreatedAsAName(): void
    {
        // 56 of the 108 distinct stored values are non-US subdivision codes, so
        // anything already shaped like a code has to survive untranslated.
        $out = CampaignCsv::decodeRow(['id' => '7', 'targeting_states' => 'A4|B2|01']);

        $this->assertSame(['A4', 'B2', '01'], $out['targeting']['states']);
    }

    public function testAStateNameWrittenLooselyStillResolves(): void
    {
        $out = CampaignCsv::decodeRow(['id' => '7', 'targeting_states' => 'new  york, NORTH-CAROLINA']);

        $this->assertSame(['NY', 'NC'], $out['targeting']['states']);
    }

    public function testARepeatedStateIsSentOnce(): void
    {
        $out = CampaignCsv::decodeRow(['id' => '7', 'targeting_states' => 'California|CA']);

        $this->assertSame(['CA'], $out['targeting']['states']);
    }

    public function testAnUnrecognisedStateLeavesTheStoredStatesAlone(): void
    {
        $ignored = null;
        $out = CampaignCsv::decodeRow(['id' => '7', 'targeting_states' => 'Califrnia'], $ignored);

        $this->assertArrayNotHasKey('states', $out['targeting'] ?? []);
        $this->assertStringContainsString('Califrnia', $ignored[0]['reason']);
        $this->assertStringContainsString('keeps its stored states', $ignored[0]['reason']);
    }

    public function testAPartlyRecognisedStatesCellAppliesTheRest(): void
    {
        $ignored = null;
        $out = CampaignCsv::decodeRow(['id' => '7', 'targeting_states' => 'California|Nowhereland'], $ignored);

        $this->assertSame(['CA'], $out['targeting']['states']);
        $this->assertStringContainsString('the rest of the cell was applied', $ignored[0]['reason']);
    }

    public function testExcludeStatesGoesThroughTheSameResolution(): void
    {
        $out = CampaignCsv::decodeRow(['id' => '7', 'targeting_exclude_states' => 'Nevada|Oregon']);

        $this->assertSame(['NV', 'OR'], $out['targeting']['exclude']['states']);
    }

    // ─────────────────────────────── dropped-cell reporting

    /**
     * @return string[] the reason text keyed by column, for the cells decodeRow
     *                  could not use
     */
    private function ignoredFor(array $row): array
    {
        $ignored = null;
        CampaignCsv::decodeRow($row, $ignored);

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

        return $out;
    }

    public function testARowThatUsesEveryCellReportsNothingIgnored(): void
    {
        $ignored = null;
        CampaignCsv::decodeRow(['name' => 'QA', 'targeting_frequency' => '50', 'targeting_browsers' => 'Chrome'], $ignored);

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

    public function testANonNumericFrequencyIsReportedAsIgnored(): void
    {
        // The cap is deliberately left alone — but silently, the row read as a
        // complete success and the user had no way to know the column did nothing.
        $ignored = $this->ignoredFor(['name' => 'QA', 'targeting_frequency' => 'abc']);

        $this->assertArrayHasKey('targeting_frequency', $ignored);
        $this->assertStringContainsString('"abc" is not a number', $ignored['targeting_frequency']);
    }

    public function testAnUnrecognisedVocabTokenIsReportedAsIgnored(): void
    {
        $ignored = $this->ignoredFor(['name' => 'QA', 'targeting_browsers' => 'Crome']);

        $this->assertArrayHasKey('targeting_browsers', $ignored);
        // Quoted back the way the user typed it, not lower-cased by the matcher.
        $this->assertStringContainsString('Crome', $ignored['targeting_browsers']);
    }

    public function testAPartlyRecognisedVocabCellAppliesTheRestAndReportsTheRemainder(): void
    {
        $row     = ['name' => 'QA', 'targeting_browsers' => 'Chrome|Crome'];
        $ignored = $this->ignoredFor($row);
        $out     = CampaignCsv::decodeRow($row);

        $this->assertSame(['chrome'], $out['targeting']['browsers']);
        $this->assertStringContainsString('Crome', $ignored['targeting_browsers']);
    }

    public function testClearingARestrictionWithAllIsNotReportedAsIgnored(): void
    {
        $ignored = $this->ignoredFor(['name' => 'QA', 'targeting_devices' => 'All Devices']);

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

    public function testATokenAlongsideAllIsReportedWhileTheRestrictionClears(): void
    {
        $row     = ['name' => 'QA', 'targeting_devices' => 'all|Tabelt'];
        $ignored = $this->ignoredFor($row);
        $out     = CampaignCsv::decodeRow($row);

        $this->assertSame([], $out['targeting']['devices'], 'all still clears the restriction');
        $this->assertStringContainsString('Tabelt', $ignored['targeting_devices']);
    }

    public function testAModeListWithoutAnIncludeOrExcludePrefixIsReported(): void
    {
        $ignored = $this->ignoredFor(['name' => 'QA', 'targeting_domain_lists' => '12|13']);

        $this->assertStringContainsString('include:', $ignored['targeting_domain_lists']);
    }

    public function testAModeListWithAnUnknownModeIsReported(): void
    {
        $ignored = $this->ignoredFor(['name' => 'QA', 'targeting_categories' => 'only:12']);

        $this->assertStringContainsString('not include or exclude', $ignored['targeting_categories']);
    }

    public function testNonNumericDomainListIdsAreReportedWhileTheNumericOnesApply(): void
    {
        $row     = ['name' => 'QA', 'targeting_domain_lists' => 'include:12|abc'];
        $ignored = $this->ignoredFor($row);
        $out     = CampaignCsv::decodeRow($row);

        $this->assertSame(['12'], $out['targeting']['domain_lists']);
        $this->assertStringContainsString('abc', $ignored['targeting_domain_lists']);
    }

    public function testAMalformedScheduleDayIsReportedWhileTheValidDaysApply(): void
    {
        // Per day, not per cell: "funday" used to vanish while mon applied.
        $row     = ['name' => 'QA', 'targeting_schedule' => 'mon 09:00-17:00|funday 9-5'];
        $ignored = $this->ignoredFor($row);
        $out     = CampaignCsv::decodeRow($row);

        $this->assertCount(1, $out['targeting']['schedule']);
        $this->assertStringContainsString('funday 9-5', $ignored['targeting_schedule']);
    }

    public function testAMalformedRetargetingCellIsReported(): void
    {
        $ignored = $this->ignoredFor(['name' => 'QA', 'targeting_retargeting' => '12|13']);

        $this->assertStringContainsString('logic:ids', $ignored['targeting_retargeting']);
    }

    public function testCapColumnsAreReportedWhenTheRowHasNoPublisher(): void
    {
        // The API only applies these on a single-publisher XML-traffic row, so a
        // cap typed on an ordinary campaign never existed.
        $ignored = $this->ignoredFor(['name' => 'QA', 'click_cap' => '500', 'conversion_cap' => '10']);

        $this->assertArrayHasKey('click_cap', $ignored);
        $this->assertArrayHasKey('conversion_cap', $ignored);
        $this->assertStringContainsString('publisher_ids', $ignored['click_cap']);
    }

    public function testCapColumnsAreReportedWhenTheRowHasSeveralPublishers(): void
    {
        $ignored = $this->ignoredFor(['name' => 'QA', 'publisher_ids' => '11|12', 'click_cap' => '500']);

        $this->assertStringContainsString('exactly one id', $ignored['click_cap']);
    }

    public function testCapColumnsAreNotReportedOnASinglePublisherRow(): void
    {
        $row     = ['name' => 'QA', 'publisher_ids' => '11', 'click_cap' => '500'];
        $ignored = $this->ignoredFor($row);
        $out     = CampaignCsv::decodeRow($row);

        $this->assertSame('500', $out['affiliate_info']['click_cap']);
        $this->assertSame([], $ignored);
    }

    public function testACellHoldingOnlySeparatorsIsReported(): void
    {
        // targeting_cities rather than targeting_states: states resolve names to
        // codes now and answer in their own words, covered by the states tests.
        $ignored = $this->ignoredFor(['name' => 'QA', 'targeting_cities' => '||']);

        $this->assertStringContainsString('no values found', $ignored['targeting_cities']);
    }

    public function testAStatesCellHoldingOnlySeparatorsIsReported(): void
    {
        $ignored = $this->ignoredFor(['name' => 'QA', 'targeting_states' => '||']);

        $this->assertStringContainsString('no states found', $ignored['targeting_states']);
    }

    // ─────────────────────────────── known columns

    public function testKnownColumnsCoversTheTemplateAndTheRollups(): void
    {
        $known = CampaignCsv::knownColumns();

        foreach (CampaignCsv::templateHeader() as $column) {
            $this->assertContains($column, $known);
        }
        // A Hearst export carries these three and the importer skips them; they
        // must not be reported as unrecognised on re-import.
        foreach (CampaignCsv::ROLLUP_COLUMNS as $column) {
            $this->assertContains($column, $known);
        }
    }
}
