<?php

namespace Tests\Feature;

use App\Controllers\BreakdownView;
use App\Models\BreakdownStatsModel;
use App\Services\Breakdown\LandingPageBreakdownService;
use App\Services\Dashboard\ReportingPeriod;
use CodeIgniter\Test\CIUnitTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use ReflectionMethod;

/**
 * ADC-176 — cross-advertiser isolation on the Breakdown View.
 *
 * The Breakdown clicks shards are per-advertiser (adv_clicks_{advId}_*), so the
 * metrics were never the problem. The dimension LABELS were: Landing Pages and
 * Ad Format resolved each row's `creative_id` against Admin.creatives with
 * `WHERE id IN (...)` and no ownership predicate. Some search rows carry the id
 * of the creative that actually served, which can belong to a different
 * advertiser — so advertiser A's own impressions/clicks/spend were rendered
 * under advertiser B's destination_url and creative_name.
 *
 * Reproduced in dev on adv 16302 (Chipotle) for Jul–Aug 2026: campaign 64902
 * (`search_13_chipotle`, owned by 16302, one linked creative of its own) carries
 * eight creative_ids owned by advertisers 13421, 19453, 19480 and 19505,
 * including `Search_13_5_LookFantastic_UK_Brand_NewSeason2026` (aid 19453 →
 * lookfantastic.com) and the WBAL/KOAT StoryStudio creatives (aid 13421).
 *
 * These tests assert the boundary in both directions, on the table path, the
 * chart path and the derived totals, using two real advertisers from the dev DB
 * (which mirrors prod). The `…IsStillExercised` test keeps the rest honest: if
 * the dev data ever stops containing foreign creative_ids, the suite says so
 * instead of passing vacuously.
 */
final class BreakdownAdvertiserScopingTest extends CIUnitTestCase
{
    /** Chipotle — the advertiser QA selected when the leak was reported. */
    private const ADV_A = 16302;

    /** THG / LookFantastic — the advertiser whose landing pages leaked into A. */
    private const ADV_B = 19453;

    /** Window in which the dev data reproduces the leak. */
    private const START = '2026-07-01';
    private const END   = '2026-08-26';

    /** Bucket used for rows whose creative is not this advertiser's. */
    private const UNKNOWN_BUCKET = 'Direct/Unknown';

    /** @var BreakdownStatsModel */
    private $model;

    protected function setUp(): void
    {
        parent::setUp();
        // The services format currency through format_money(); BaseController
        // autoloads that helper for web requests, the test harness does not.
        helper(['utils', 'auth']);
        $this->model = new BreakdownStatsModel();
    }

    protected function tearDown(): void
    {
        session()->destroy();
        parent::tearDown();
    }

    // ---------------------------------------------------------------- helpers

    private function period(): ReportingPeriod
    {
        return ReportingPeriod::fromInclusiveDates(self::START, self::END);
    }

    private function adminDb()
    {
        return \Config\Database::connect('default');
    }

    /** creative_ids present in an advertiser's clicks shards for the window. */
    private function clickedCreativeIds(int $advId): array
    {
        $method = new ReflectionMethod(BreakdownStatsModel::class, 'getTables');
        $method->setAccessible(true);
        $tables = $method->invoke($this->model, $advId, self::START, self::END);

        if (empty($tables)) {
            return [];
        }

        $clicksDb = new ReflectionMethod(BreakdownStatsModel::class, 'clicksDb');
        $clicksDb->setAccessible(true);

        $parts = [];
        $binds = [];
        foreach ($tables as $table) {
            $parts[] = "SELECT DISTINCT creative_id FROM `{$table}` WHERE date >= ? AND date <= ? AND status >= 1";
            $binds[] = self::START;
            $binds[] = self::END;
        }

        $rows = $clicksDb->invoke($this->model, $advId)
            ->query('SELECT DISTINCT creative_id FROM (' . implode(' UNION ALL ', $parts) . ') t', $binds)
            ->getResultArray();

        return array_values(array_filter(array_map('intval', array_column($rows, 'creative_id'))));
    }

    /** @return array<int,int> creative_id => owning aid, for ids that exist. */
    private function creativeOwners(array $creativeIds): array
    {
        $ids = array_values(array_filter(array_map('intval', $creativeIds)));
        if (empty($ids)) {
            return [];
        }

        $rows = $this->adminDb()
            ->query('SELECT id, aid FROM creatives WHERE id IN (' . implode(',', $ids) . ')')
            ->getResultArray();

        $owners = [];
        foreach ($rows as $r) {
            $owners[(int) $r['id']] = (int) $r['aid'];
        }

        return $owners;
    }

    /** Destination URLs owned by an advertiser (raw, as stored). */
    private function ownedDestinationUrls(int $advId): array
    {
        $rows = $this->adminDb()
            ->query('SELECT DISTINCT COALESCE(destination_url, "") AS url FROM creatives WHERE aid = ?', [$advId])
            ->getResultArray();

        return array_column($rows, 'url');
    }

    /** Creative names owned by an advertiser. */
    private function ownedCreativeNames(int $advId): array
    {
        $rows = $this->adminDb()
            ->query('SELECT DISTINCT COALESCE(creative_name, "") AS n FROM creatives WHERE aid = ? AND creative_name <> ""', [$advId])
            ->getResultArray();

        return array_column($rows, 'n');
    }

    /**
     * Loose comparison key for ownership checks only.
     *
     * The model reports landing pages verbatim; this is deliberately *looser* than
     * the model so a foreign page cannot hide behind a spelling difference — if
     * adv B owns `https://www.lookfantastic.it/` and adv A's tab shows
     * `lookfantastic.it`, the test must still call it a leak. Never used to assert
     * what the tab displays, only to ask who owns a value.
     */
    private function canonicalPage(string $url): string
    {
        $u = strtolower(trim($url));
        $u = preg_replace('~^[a-z][a-z0-9+.-]*://~', '', $u);
        $u = preg_replace('~^www\\.~', '', (string) $u);

        return rtrim((string) $u, '/');
    }

    /**
     * SQL half of canonicalPage(), applied to an Admin.creatives URL column so both
     * sides of the ownership comparison are reduced the same way.
     */
    private function canonicalPageSql(string $column): string
    {
        $lower = "LOWER({$column})";

        return "TRIM(TRAILING '/' FROM TRIM(LEADING 'www.' FROM "
            . "IF(LOCATE('://', {$lower}) > 0, SUBSTRING({$lower}, LOCATE('://', {$lower}) + 3), {$lower})))";
    }

    /**
     * Which advertisers own a creative pointing at each of these pages?
     *
     * Both columns are checked because the two engines label rows differently:
     * HeatWave groups by `landing_page_url` (a copy of `creatives.creative_url`,
     * the display domain), legacy by `creatives.destination_url`.
     *
     * A value with NO owner is not a failure — HeatWave's denormalised columns are
     * a serve-time snapshot, so a page can outlive the creative that used it. Only
     * a value owned by someone *else* is a leak.
     *
     * @param string[] $urls
     * @return array<string,int[]> url => owning aids
     */
    private function pageOwners(array $urls): array
    {
        $urls = array_values(array_filter(array_unique($urls), static fn ($u) => $u !== ''));
        if (empty($urls)) {
            return [];
        }

        $db = $this->adminDb();
        // Reduce both sides before comparing: dimension values and creative_url are
        // both stored raw and inconsistently ('www.lookfantastic.es' vs
        // 'https://www.lookfantastic.es/'), so exact matching here would leave real
        // foreign pages looking unattributable and the leak check would pass
        // vacuously.
        $canonUrls = array_unique(array_map([$this, 'canonicalPage'], $urls));
        $in        = implode(',', array_map([$db, 'escape'], $canonUrls));
        $cu        = $this->canonicalPageSql('creative_url');
        $du        = $this->canonicalPageSql('destination_url');

        $rows = $db->query(
            "SELECT {$cu} AS u, aid FROM creatives WHERE {$cu} IN ({$in})
             UNION
             SELECT {$du} AS u, aid FROM creatives WHERE {$du} IN ({$in})"
        )->getResultArray();

        $owners = [];
        foreach ($rows as $r) {
            $owners[(string) $r['u']][(int) $r['aid']] = (int) $r['aid'];
        }

        return array_map('array_values', $owners);
    }

    /**
     * Which advertisers own a creative with each of these names? Same
     * stale-snapshot caveat as pageOwners().
     *
     * @param string[] $names
     * @return array<string,int[]> name => owning aids
     */
    private function creativeNameOwners(array $names): array
    {
        $names = array_values(array_filter(array_unique($names), static fn ($n) => $n !== ''));
        if (empty($names)) {
            return [];
        }

        $db = $this->adminDb();
        $in = implode(',', array_map([$db, 'escape'], $names));

        $rows = $db->query("SELECT creative_name AS n, aid FROM creatives WHERE creative_name IN ({$in})")
            ->getResultArray();

        $owners = [];
        foreach ($rows as $r) {
            $owners[(string) $r['n']][(int) $r['aid']] = (int) $r['aid'];
        }

        return array_map('array_values', $owners);
    }

    private function skipUnlessClicksDataAvailable(int $advId): array
    {
        $ids = $this->clickedCreativeIds($advId);
        if (empty($ids)) {
            $this->markTestSkipped("No adv_clicks data for adv {$advId} in " . self::START . '..' . self::END
                . ' — the clicks DB is unreachable from this environment or the shards were pruned.');
        }

        return $ids;
    }

    // ------------------------------------------------------------- guard test

    /**
     * Without foreign creative_ids in the fixture data the isolation tests below
     * would pass no matter what the code does. Fail loudly if that happens.
     */
    public function testTheLeakScenarioIsStillExercisedByTheFixtureData(): void
    {
        $ids    = $this->skipUnlessClicksDataAvailable(self::ADV_A);
        $owners = $this->creativeOwners($ids);

        $foreign = array_filter($owners, static fn ($aid) => $aid !== self::ADV_A);

        $this->assertNotEmpty(
            $foreign,
            'Adv ' . self::ADV_A . "'s clicks shards no longer reference creatives owned by other advertisers, "
            . 'so this test class can no longer prove the fix. Pick a window/advertiser pair that still does.'
        );
    }

    // ------------------------------------------------- model-level isolation

    /**
     * The lookup at the heart of the bug: ids owned by someone else must not
     * resolve, in either direction.
     */
    public function testOwnedCreativeMetaExcludesOtherAdvertisersCreatives(): void
    {
        $method = new ReflectionMethod(BreakdownStatsModel::class, 'getOwnedCreativeMeta');
        $method->setAccessible(true);

        $ownA     = $this->adminDb()->query('SELECT id FROM creatives WHERE aid = ? LIMIT 5', [self::ADV_A])->getResultArray();
        $ownB     = $this->adminDb()->query('SELECT id FROM creatives WHERE aid = ? LIMIT 5', [self::ADV_B])->getResultArray();
        $idsA     = array_map('intval', array_column($ownA, 'id'));
        $idsB     = array_map('intval', array_column($ownB, 'id'));

        $this->assertNotEmpty($idsA, 'fixture: adv ' . self::ADV_A . ' owns no creatives');
        $this->assertNotEmpty($idsB, 'fixture: adv ' . self::ADV_B . ' owns no creatives');

        $metaForA = $method->invoke($this->model, self::ADV_A, array_merge($idsA, $idsB), 'test');
        $metaForB = $method->invoke($this->model, self::ADV_B, array_merge($idsA, $idsB), 'test');

        $this->assertSame($idsA, array_values(array_intersect($idsA, array_keys($metaForA))), 'A lost its own creatives');
        $this->assertEmpty(array_intersect($idsB, array_keys($metaForA)), "adv B's creatives resolved for adv A");

        $this->assertSame($idsB, array_values(array_intersect($idsB, array_keys($metaForB))), 'B lost its own creatives');
        $this->assertEmpty(array_intersect($idsA, array_keys($metaForB)), "adv A's creatives resolved for adv B");
    }

    /**
     * Every landing-page value an advertiser sees must be its own page.
     *
     * On HeatWave the values come from `adv_clicks_{advId}_new.landing_page_url`
     * (the display domain); on the legacy path from the aid-scoped
     * `creatives.destination_url` join. Either way, a value that is provably
     * another advertiser's page is the defect. A value nobody owns is fine —
     * HeatWave's column is a serve-time snapshot and a page can outlive its
     * creative.
     */
    #[DataProvider('advertiserProvider')]
    public function testLandingPageValuesAreNeverAnotherAdvertisersPage(int $advId, int $otherAdvId): void
    {
        $this->skipUnlessClicksDataAvailable($advId);

        $series = $this->model->getTimeSeriesByDimension($advId, self::START, self::END, 'landing_page');
        $dims   = $series['dimensions'] ?? [];
        $this->assertNotEmpty($dims, "no landing-page rows for adv {$advId}");

        $owners = $this->pageOwners($dims);

        foreach ($dims as $url) {
            if ($url === self::UNKNOWN_BUCKET) {
                continue;
            }

            $these = $owners[$this->canonicalPage((string) $url)] ?? [];
            if (empty($these)) {
                continue; // unattributable snapshot value, not a leak
            }

            $this->assertContains(
                $advId,
                $these,
                "adv {$advId} landing-page row '{$url}' is owned only by adv " . implode(',', $these)
            );
            $this->assertNotSame(
                [$otherAdvId],
                $these,
                "adv {$advId} landing-page row '{$url}' belongs to adv {$otherAdvId}"
            );
        }
    }

    /**
     * With HeatWave in play the landing page must come out of the advertiser's own
     * flat table and nowhere else — that is what makes a cross-advertiser value
     * structurally impossible rather than merely filtered out.
     */
    public function testLandingPageValuesComeFromTheAdvertisersOwnHeatwaveTable(): void
    {
        $this->skipUnlessClicksDataAvailable(self::ADV_A);

        $useHeatwave = new ReflectionMethod(BreakdownStatsModel::class, 'useHeatwave');
        $useHeatwave->setAccessible(true);
        if (!$useHeatwave->invoke($this->model, self::ADV_A)) {
            $this->markTestSkipped('adv ' . self::ADV_A . ' is not on HeatWave here; the legacy path is covered above.');
        }

        $hw   = \Config\Database::connect('heatwave');
        $rows = $hw->query(
            "SELECT DISTINCT landing_page_url COLLATE utf8mb4_bin AS url
               FROM `adv_clicks_" . self::ADV_A . "_new`
              WHERE date >= ? AND date <= ? AND status >= 1",
            [self::START, self::END]
        )->getResultArray();
        // Exact, byte-for-byte membership: the tab reports the stored value
        // verbatim, so every row it shows must be a string that literally exists in
        // this advertiser's own table (empty/NULL surfacing as the unknown bucket).
        $inOwnTable = array_map(
            static fn (array $r): string => ((string) ($r['url'] ?? '')) === ''
                ? self::UNKNOWN_BUCKET
                : (string) $r['url'],
            $rows
        );

        $series = $this->model->getTimeSeriesByDimension(self::ADV_A, self::START, self::END, 'landing_page');

        foreach ($series['dimensions'] ?? [] as $url) {
            $this->assertContains(
                (string) $url,
                $inOwnTable,
                "landing page '{$url}' is not present in adv " . self::ADV_A . "'s own HeatWave table"
            );
        }
    }

    /**
     * The Creatives column comes from a second query — same boundary applies.
     * On HeatWave it reads `_new.creative_name`, which the ETL resolves through the
     * campaign (not through creative_id), so it is advertiser-clean at source.
     */
    #[DataProvider('advertiserProvider')]
    public function testCreativeNamesPerLandingPageAreNeverAnotherAdvertisers(int $advId, int $otherAdvId): void
    {
        $this->skipUnlessClicksDataAvailable($advId);

        $map = $this->model->getCreativeNamesByLandingPage($advId, self::START, self::END);
        $this->assertNotEmpty($map, "no Creatives-column data for adv {$advId}");

        // Only names long enough to be distinctive are substring-matched: adv 13421
        // owns a creative named 'x', and strpos() would then match any cell
        // containing that letter. Exact token ownership below is the real check.
        $otherOnly = array_filter(
            array_diff($this->ownedCreativeNames($otherAdvId), $this->ownedCreativeNames($advId)),
            static fn ($n) => strlen((string) $n) >= 12
        );

        $tokens = [];
        foreach ($map as $url => $names) {
            $cell = (string) $names;

            foreach ($otherOnly as $foreignName) {
                $this->assertStringNotContainsString(
                    $foreignName,
                    $cell,
                    "adv {$advId} Creatives column for '{$url}' names adv {$otherAdvId}'s creative '{$foreignName}'"
                );
            }

            // Names are joined with ', '; a name containing that sequence splits, so
            // ownership is judged per token and unknown tokens are tolerated.
            foreach (explode(', ', $cell) as $token) {
                if ($token !== '') {
                    $tokens[$token] = $url;
                }
            }
        }

        $owners = $this->creativeNameOwners(array_keys($tokens));

        foreach ($tokens as $token => $url) {
            $these = $owners[$token] ?? [];
            if (empty($these)) {
                continue; // serve-time snapshot of a since-renamed/deleted creative
            }

            $this->assertContains(
                $advId,
                $these,
                "adv {$advId} Creatives column lists '{$token}' (landing page '{$url}'), owned only by adv "
                . implode(',', $these)
            );
        }
    }

    // -------------------------------------------- rendered table / chart / totals

    /**
     * The exact rows QA saw: table labels, the Creatives column, the chart series
     * keys and the totals row must contain none of the leaked identifiers.
     */
    public function testRenderedTableChartAndTotalsCarryNoForeignIdentifiers(): void
    {
        $this->skipUnlessClicksDataAvailable(self::ADV_A);

        $service = new LandingPageBreakdownService();
        $config  = ['metrics' => ['impressions', 'clicks', 'spend', 'conversions', 'ctr', 'ecpc', 'ecpm'], 'limit' => 50];

        $table = $service->getTableData(self::ADV_A, $this->period(), [], $config);
        $chart = $service->getChartData(self::ADV_A, $this->period(), [], $config);

        $haystack = strtolower(json_encode([
            'rows'       => $table['rows'] ?? [],
            'totals'     => $table['totals'] ?? [],
            'breakdowns' => $chart['breakdowns'] ?? [],
            'kpis'       => $chart['kpis'] ?? [],
        ]));

        // Reported evidence, plus the other advertisers' creatives that the same
        // campaign's rows point at.
        $leaked = [
            'lookfantastic',
            'wbaltv',
            'koat.com',
            'ancestry.com',
            'norwegiancruise',
            'search_13_5_lookfantastic_uk_brand_newseason2026',
        ];

        foreach ($leaked as $needle) {
            $this->assertStringNotContainsString(
                $needle,
                $haystack,
                "adv " . self::ADV_A . " breakdown output still contains '{$needle}'"
            );
        }

        $this->assertNotEmpty($table['rows'] ?? [], 'table went empty — scoping must not delete the advertiser own rows');
    }

    /**
     * Scoping must not silently drop the advertiser's own spend: the totals row
     * still has to reconcile with the sum of the rows behind it.
     */
    public function testTotalsStillReconcileWithTheScopedRows(): void
    {
        $this->skipUnlessClicksDataAvailable(self::ADV_A);

        $service = new LandingPageBreakdownService();
        $table   = $service->getTableData(self::ADV_A, $this->period(), [], ['metrics' => ['impressions', 'clicks', 'spend'], 'limit' => 50]);

        $rows = $table['rows'] ?? [];
        $this->assertNotEmpty($rows);

        $impressions = 0;
        $clicks      = 0;
        foreach ($rows as $row) {
            $impressions += (int) $row['impressions'];
            $clicks      += (int) $row['clicks'];
        }

        $this->assertSame($impressions, (int) $table['totals']['impressions']);
        $this->assertSame($clicks, (int) $table['totals']['clicks']);
    }

    // ------------------------------------------------------ controller scoping

    /** A campaign belonging to another advertiser cannot be used as a filter. */
    public function testForeignCampaignFilterIsRefusedServerSide(): void
    {
        $ownCampaign = $this->adminDb()
            ->query('SELECT id FROM campaign WHERE advertiser_id = ? LIMIT 1', [self::ADV_A])
            ->getRowArray();
        $foreignCampaign = $this->adminDb()
            ->query('SELECT id FROM campaign WHERE advertiser_id = ? LIMIT 1', [self::ADV_B])
            ->getRowArray();

        $this->assertNotEmpty($ownCampaign, 'fixture: adv ' . self::ADV_A . ' has no campaign');
        $this->assertNotEmpty($foreignCampaign, 'fixture: adv ' . self::ADV_B . ' has no campaign');

        $own     = (int) $ownCampaign['id'];
        $foreign = (int) $foreignCampaign['id'];

        $method = new ReflectionMethod(BreakdownView::class, 'scopedCampaignFilter');
        $method->setAccessible(true);
        $controller = new BreakdownView();

        // Own id passes through; anything foreign in the list refuses the whole
        // request (null → 403) instead of widening the query.
        $this->assertSame([$own], $method->invoke($controller, self::ADV_A, [$own]));
        $this->assertNull($method->invoke($controller, self::ADV_A, [$foreign]));
        $this->assertNull($method->invoke($controller, self::ADV_A, [$own, $foreign]));
        $this->assertSame([$foreign], $method->invoke($controller, self::ADV_B, [$foreign]));
        $this->assertNull($method->invoke($controller, self::ADV_B, [$own]));
        $this->assertSame([], $method->invoke($controller, self::ADV_A, []), 'no filter stays no filter');
    }

    /**
     * Only tables derived from this advertiser's numeric id may be queried.
     * SHOW TABLES ... LIKE 'adv_clicks_{id}_%' treats '_' as a wildcard, so the
     * result set can legitimately contain a longer advertiser's table.
     */
    public function testOnlyThisAdvertisersClicksTablesAreAccepted(): void
    {
        $method = new ReflectionMethod(BreakdownStatsModel::class, 'getTables');
        $method->setAccessible(true);

        $tables = $method->invoke($this->model, self::ADV_A, self::START, self::END);

        foreach ($tables as $table) {
            $this->assertMatchesRegularExpression(
                '/^adv_clicks_' . self::ADV_A . '_(?:\d{6}|new)$/',
                $table,
                "getTables() returned '{$table}', which is not adv " . self::ADV_A . "'s table"
            );
        }
    }

    /**
     * Cached breakdown responses must never be reachable across advertisers, so the
     * advertiser id has to be part of the key (dimension, window and filters too).
     */
    public function testBreakdownCacheKeysAreAdvertiserSpecific(): void
    {
        $service = new LandingPageBreakdownService();

        $method = new ReflectionMethod(\App\Services\Breakdown\BreakdownService::class, 'withCache');
        $method->setAccessible(true);

        // Caching is off today ($cacheable = false). Force it on for this instance
        // so the wrapper is actually exercised rather than short-circuited.
        $prop = new \ReflectionProperty(\App\Services\Breakdown\BreakdownService::class, 'cacheable');
        $prop->setAccessible(true);
        $wasCacheable = $prop->getValue($service);
        $prop->setValue($service, true);

        try {
            $period = $this->period();
            $put    = static fn (string $marker) => static fn () => ['marker' => $marker];

            $first  = $method->invoke($service, 'isolationtest', self::ADV_A, $period, [], $put('A'), false);
            $second = $method->invoke($service, 'isolationtest', self::ADV_B, $period, [], $put('B'), false);
            $again  = $method->invoke($service, 'isolationtest', self::ADV_A, $period, [], $put('A-rebuilt'), false);

            $this->assertSame(['marker' => 'A'], $first);
            $this->assertSame(['marker' => 'B'], $second, "adv B was served adv A's cached breakdown");
            // Proves the cache was really in play, so the assertion above is not vacuous.
            $this->assertSame(['marker' => 'A'], $again, 'expected a cache hit for adv A');
        } finally {
            // The two entries use a synthetic 'isolationtest' kind, so they cannot
            // collide with real breakdown entries; they age out with the 15-min TTL.
            $prop->setValue($service, $wasCacheable);
        }
    }

    // ----------------------------------------------------------- static fence

    /**
     * Every Admin.creatives read in the breakdown model must carry an ownership
     * predicate. Cheap fence so the next person to add a creative lookup here
     * cannot reintroduce the unscoped `WHERE id IN (...)` form.
     */
    public function testNoUnscopedCreativesLookupRemainsInTheBreakdownModel(): void
    {
        $source = (string) file_get_contents(APPPATH . 'Models/BreakdownStatsModel.php');

        // Statements, not comments: strip comment lines first.
        $code = implode("\n", array_filter(
            explode("\n", $source),
            static fn ($line) => !preg_match('~^\s*(\*|//|/\*)~', $line)
        ));

        preg_match_all('~FROM\s+creatives\b.{0,400}~is', $code, $matches);
        $this->assertNotEmpty($matches[0], 'expected the model to still read Admin.creatives');

        foreach ($matches[0] as $fragment) {
            $this->assertMatchesRegularExpression(
                '~\baid\s*=~i',
                $fragment,
                "an Admin.creatives read in BreakdownStatsModel has no aid predicate:\n" . $fragment
            );
        }
    }

    /** @return array<string,array{0:int,1:int}> */
    public static function advertiserProvider(): array
    {
        return [
            'chipotle sees only chipotle'      => [self::ADV_A, self::ADV_B],
            'lookfantastic sees only its own'  => [self::ADV_B, self::ADV_A],
        ];
    }
}
