<?php

namespace Tests\Feature;

use App\Services\AdOps\ListsRepository;
use App\Services\AdOps\ListsService;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\FeatureTestTrait;

/**
 * Real-user simulation for the Ad Ops Lists page (AP-30).
 *
 * Each test drives an actual HTTP request through the full stack — routing,
 * the auth + adops filters, the real Lists controllers, ListsService and
 * ListsRepository — against the dev Admin DB, then asserts the DB side effects
 * a user would expect. Everything runs under a throwaway advertiser id and is
 * cleaned up in tearDown so no real advertiser data is touched.
 *
 * Run with the feature bootstrap (defines the env/DB constants the web entry
 * normally sets) and newrelic disabled:
 *   php8.2 -d newrelic.enabled=0 -d newrelic.daemon.dont_launch=3 \
 *     vendor/bin/phpunit --bootstrap tests/feature_bootstrap.php tests/feature
 */
final class ListsFlowTest extends CIUnitTestCase
{
    use FeatureTestTrait;

    /** Throwaway advertiser id — keeps the run isolated from real data. */
    private const ADV = 999999992;

    private array $sess;

    /** @var \CodeIgniter\Database\BaseConnection */
    private $conn;

    protected function setUp(): void
    {
        parent::setUp();

        $this->sess = [
            'logged_in' => 1,
            'adv_id'    => self::ADV,
            'user_id'   => self::ADV,
            'username'  => 'QA Bot',
            'mngtuser'  => 'qabot',
            'is_ad_ops' => 1,
        ];

        // The lists/* routes apply the CSRF filter to non-GET requests; drop it
        // for these in-process tests (we're exercising controller/DB behaviour,
        // not the token round-trip, which the browser/view already handles).
        $filters = config('Filters');
        unset($filters->filters['csrf']);

        $this->conn = \Config\Database::connect('default');
        $this->cleanup();
    }

    protected function tearDown(): void
    {
        $this->cleanup();
        parent::tearDown();
    }

    private function cleanup(): void
    {
        // keyword lists + their keyword rows
        $ids = $this->conn->table('keyword_lists')->select('id')->where('advertiser_id', self::ADV)->get()->getResultArray();
        foreach ($ids as $r) {
            $this->conn->table('rep_keywords_broad')->where('list_id', (int) $r['id'])->delete();
            $this->conn->table('rep_keywords_exact')->where('list_id', (int) $r['id'])->delete();
        }
        $this->conn->table('keyword_lists')->where('advertiser_id', self::ADV)->delete();

        // campaigns used by the mapping-upload tests + their targeting
        $camps = $this->conn->table('campaign')->select('id')->where('advertiser_id', self::ADV)->get()->getResultArray();
        foreach ($camps as $r) {
            $this->conn->table('campaign_targeting')->where('campaign_id', (int) $r['id'])->delete();
        }
        $this->conn->table('campaign')->where('advertiser_id', self::ADV)->delete();

        // domain lists + domains
        $dids = $this->conn->table('domain_list_names')->select('list_id')->where('aid', self::ADV)->get()->getResultArray();
        foreach ($dids as $r) {
            $this->conn->table('ads_domain_list')->where('list_id', (int) $r['list_id'])->delete();
        }
        $this->conn->table('domain_list_names')->where('aid', self::ADV)->delete();

        // source lists + sources
        $sids = $this->conn->table('source_lists')->select('id')->where('advertiser_id', self::ADV)->get()->getResultArray();
        foreach ($sids as $r) {
            $this->conn->table('sources')->where('list_id', (int) $r['id'])->delete();
        }
        $this->conn->table('source_lists')->where('advertiser_id', self::ADV)->delete();
    }

    private function req()
    {
        return $this->withSession($this->sess);
    }

    /* ───────────────── keyword lists ───────────────── */

    public function testUserCreatesKeywordListWithBid(): void
    {
        $this->req()->post('lists/keyword-lists/create', [
            'name'     => 'QA KW List',
            'type'     => 'broad',
            'bid'      => '0.250000',
            'keywords' => "red shoes\nblue shoes",
        ]);

        $list = $this->conn->table('keyword_lists')
            ->where('advertiser_id', self::ADV)->where('name', 'QA KW List')
            ->get()->getRowArray();

        $this->assertNotNull($list, 'keyword list row should exist');
        $this->assertSame('broad', $list['type']);
        $this->assertSame('0.250000', (string) $list['bid']);

        $count = $this->conn->table('rep_keywords_broad')
            ->where('list_id', (int) $list['id'])->where('status', 1)->countAllResults();
        $this->assertSame(2, $count, 'both keywords stored');
    }

    public function testUserSearchesFindReplacesAndDeletesKeywords(): void
    {
        // Arrange: a list with two keywords.
        $this->req()->post('lists/keyword-lists/create', [
            'name'     => 'QA FR List',
            'type'     => 'broad',
            'keywords' => "alpha widget\nbeta widget",
        ]);
        $listId = (int) $this->conn->table('keyword_lists')
            ->where('advertiser_id', self::ADV)->where('name', 'QA FR List')
            ->get()->getRowArray()['id'];

        // Search finds them.
        $search = $this->req()->get('lists/keywords?q=widget&offset=0&limit=50');
        $search->assertStatus(200);
        $items = json_decode($search->getJSON(), true)['items'];
        $kw    = array_column($items, 'keyword');
        $this->assertContains('alpha widget', $kw);
        $this->assertContains('beta widget', $kw);

        // Find & replace widget -> gadget on this list.
        $this->req()->post('lists/keywords/find-replace', [
            'find'     => 'widget',
            'replace'  => 'gadget',
            'list_ids' => [$listId],
        ]);
        $after = $this->conn->table('rep_keywords_broad')
            ->where('list_id', $listId)->where('status', 1)
            ->orderBy('keyword')->get()->getResultArray();
        $this->assertSame(['alpha gadget', 'beta gadget'], array_column($after, 'keyword'));

        // Remove Selected: soft-delete the first one by composite id.
        $firstId = (int) $after[0]['id'];
        $this->req()->post('lists/keywords/delete', ['ids' => [$firstId . '_broad']]);
        $row = $this->conn->table('rep_keywords_broad')->where('id', $firstId)->get()->getRowArray();
        $this->assertSame(0, (int) $row['status'], 'selected keyword soft-deleted');
    }

    public function testInvalidKeywordTypeDoesNotBreakCreate(): void
    {
        // The form only offers broad/exact now; a stray value normalizes to broad
        // instead of the old 422 that produced "Error while saving".
        $this->req()->post('lists/keyword-lists/create', [
            'name'     => 'QA Norm List',
            'type'     => 'phrase', // not a valid enum
            'keywords' => 'normalize me',
        ]);
        $list = $this->conn->table('keyword_lists')
            ->where('advertiser_id', self::ADV)->where('name', 'QA Norm List')
            ->get()->getRowArray();
        $this->assertNotNull($list);
        $this->assertSame('broad', $list['type']);
    }

    public function testKeywordsCarryTheirDestinationUrlFromTheTextarea(): void
    {
        // Prod's documented textarea format is "keyword, destination url" per
        // line. Splitting on commas as keyword separators turned one line into
        // two keywords — the url being one of them — and stored no url at all.
        $this->req()->post('lists/keyword-lists/create', [
            'name'     => 'QA Dest List',
            'type'     => 'broad',
            'keywords' => "red shoes,https://example.com/red?a=1,b=2\nblue shoes\n  \nred shoes,https://example.com/newer",
        ]);
        $listId = (int) $this->conn->table('keyword_lists')
            ->where('advertiser_id', self::ADV)->where('name', 'QA Dest List')
            ->get()->getRowArray()['id'];

        $rows = $this->conn->table('rep_keywords_broad')
            ->select('keyword, destination_url')
            ->where('list_id', $listId)->where('status', 1)
            ->orderBy('keyword')->get()->getResultArray();

        $this->assertSame(['blue shoes', 'red shoes'], array_column($rows, 'keyword'),
            'the url must not become a keyword of its own, and blanks are dropped');

        $byKeyword = array_column($rows, 'destination_url', 'keyword');
        // Only the FIRST comma splits — a url may contain its own.
        $this->assertSame('https://example.com/newer', $byKeyword['red shoes'],
            'repeated keyword keeps the last url given');
        $this->assertSame('', $byKeyword['blue shoes'], 'a url is optional');
    }


    public function testKeywordListDetailReturnsItsKeywordsForTheEditModal(): void
    {
        // The modal reads res.keywords; getKeywordList() selects id/name/type/bid
        // only, so the key was absent and the textarea was always blank.
        $this->req()->post('lists/keyword-lists/create', [
            'name'     => 'QA Detail List',
            'type'     => 'broad',
            'keywords' => "red shoes,https://example.com/red\nblue shoes",
        ]);
        $listId = (int) $this->conn->table('keyword_lists')
            ->where('advertiser_id', self::ADV)->where('name', 'QA Detail List')
            ->get()->getRowArray()['id'];

        $res = $this->req()->get('lists/keyword-lists/json/' . $listId);
        $res->assertStatus(200);
        $body = json_decode((string) $res->response()->getBody(), true);

        $this->assertSame('QA Detail List', $body['name'], 'the record fields still come back');
        $this->assertArrayHasKey('keywords', $body);
        $this->assertSame(['blue shoes', 'red shoes'], array_column($body['keywords'], 'keyword'));

        $byKeyword = array_column($body['keywords'], 'destination_url', 'keyword');
        $this->assertSame('https://example.com/red', $byKeyword['red shoes'],
            'the url has to come back too, or an edit save would drop it');
    }

    /**
     * Helper: a keyword list owned by the throwaway advertiser, with $n
     * generated keywords written straight to the type table.
     */
    private function makeGeneratedKeywordList(string $name, int $n, string $type = 'broad'): int
    {
        $this->conn->table('keyword_lists')->insert([
            'advertiser_id' => self::ADV,
            'name'          => $name,
            'type'          => $type,
            'status'        => 1,
        ]);
        $listId = (int) $this->conn->insertID();

        if ($n > 0) {
            $rows = [];
            for ($i = 0; $i < $n; $i++) {
                $rows[] = [
                    'list_id'         => $listId,
                    'keyword'         => sprintf('kw%06d', $i),
                    'destination_url' => '',
                    'status'          => 1,
                ];
            }
            foreach (array_chunk($rows, 1000) as $chunk) {
                $this->conn->table('rep_keywords_' . $type)->insertBatch($chunk);
            }
        }

        return $listId;
    }

    public function testEditModalRefusesToPrefillAListTooLargeToEditInline(): void
    {
        // One past the 5000 cap. A truncated prefill would be worse than a blank
        // box: a non-empty textarea means "replace the list", so the rows past
        // the cap would be deleted on save.
        $listId = $this->makeGeneratedKeywordList('QA Huge List', 5001);

        $res = $this->req()->get('lists/keyword-lists/json/' . $listId);
        $res->assertStatus(200);
        $body = json_decode((string) $res->response()->getBody(), true);

        $this->assertFalse($body['keywords_editable'], 'a list past the cap is not inline-editable');
        $this->assertSame([], $body['keywords'], 'and none of it is sent');
        $this->assertSame(5000, $body['keywords_cap']);
        $this->assertSame('QA Huge List', $body['name'], 'the record fields still come back');
    }

    public function testEditModalStillPrefillsAListExactlyAtTheCap(): void
    {
        $listId = $this->makeGeneratedKeywordList('QA Cap List', 5000);

        $res = $this->req()->get('lists/keyword-lists/json/' . $listId);
        $body = json_decode((string) $res->response()->getBody(), true);

        $this->assertTrue($body['keywords_editable'], 'exactly at the cap is still editable');
        $this->assertCount(5000, $body['keywords']);
    }

    /* ───────────────── flat keywords tab ───────────────── */

    public function testFlatKeywordSearchPaginatesInSqlAndReportsTheWholeTotal(): void
    {
        // Pagination used to happen in PHP after selecting the advertiser's
        // entire keyword set; total must still describe the whole match, not
        // just the page.
        $this->makeGeneratedKeywordList('QA Page List', 7);

        $res  = $this->req()->get('lists/keywords?offset=0&limit=3&sort=keyword&dir=asc');
        $body = json_decode((string) $res->response()->getBody(), true);

        $this->assertSame(7, $body['total'], 'total counts every match, not the page');
        $this->assertCount(3, $body['items']);
        $this->assertSame(['kw000000', 'kw000001', 'kw000002'], array_column($body['items'], 'keyword'));

        $res2  = $this->req()->get('lists/keywords?offset=3&limit=3&sort=keyword&dir=asc');
        $body2 = json_decode((string) $res2->response()->getBody(), true);
        $this->assertSame(['kw000003', 'kw000004', 'kw000005'], array_column($body2['items'], 'keyword'));

        // A composite id is what the delete endpoint expects back.
        $this->assertMatchesRegularExpression('/^\d+_broad$/', $body['items'][0]['id']);
    }

    public function testFlatKeywordSearchTreatsLikeWildcardsAsLiterals(): void
    {
        $listId = $this->makeGeneratedKeywordList('QA Wildcard List', 0);
        $this->conn->table('rep_keywords_broad')->insertBatch([
            ['list_id' => $listId, 'keyword' => 'a_b', 'destination_url' => '', 'status' => 1],
            ['list_id' => $listId, 'keyword' => 'axb', 'destination_url' => '', 'status' => 1],
            ['list_id' => $listId, 'keyword' => '50% off', 'destination_url' => '', 'status' => 1],
            ['list_id' => $listId, 'keyword' => '50 off', 'destination_url' => '', 'status' => 1],
        ]);

        $res  = $this->req()->get('lists/keywords?q=' . urlencode('a_b'));
        $body = json_decode((string) $res->response()->getBody(), true);
        $this->assertSame(['a_b'], array_column($body['items'], 'keyword'),
            'an underscore is a literal, not a single-character wildcard');

        $res2  = $this->req()->get('lists/keywords?q=' . urlencode('50%'));
        $body2 = json_decode((string) $res2->response()->getBody(), true);
        $this->assertSame(['50% off'], array_column($body2['items'], 'keyword'),
            'a percent sign is a literal, not a wildcard');
    }

    public function testPhraseAndBroadFormsOfOneKeywordBothSurviveASave(): void
    {
        // A quoted keyword is a phrase-match keyword, and real lists carry both
        // forms deliberately (list 10109 in prod holds 366 rows that are 183 such
        // pairs). insertKeywords used to replace the quotes with a space before
        // de-duplicating, so saving collapsed each pair onto one row and silently
        // dropped half the list.
        $this->req()->post('lists/keyword-lists/create', [
            'name'     => 'QA Phrase List',
            'type'     => 'broad',
            'keywords' => "\"apr de auto toyota\"\napr de auto toyota",
        ]);
        $listId = (int) $this->conn->table('keyword_lists')
            ->where('advertiser_id', self::ADV)->where('name', 'QA Phrase List')
            ->get()->getRowArray()['id'];

        $kws = array_column($this->conn->table('rep_keywords_broad')
            ->select('keyword')->where('list_id', $listId)->where('status', 1)
            ->orderBy('keyword', 'asc')->get()->getResultArray(), 'keyword');

        $this->assertSame(['"apr de auto toyota"', 'apr de auto toyota'], $kws,
            'the phrase form and the broad form are two keywords, not one');
    }

    public function testDeletingKeywordsReportsTheRowsItActuallyRemoved(): void
    {
        $listId = $this->makeGeneratedKeywordList('QA Delete List', 4);
        $ids    = $this->conn->table('rep_keywords_broad')
            ->select('id')->where('list_id', $listId)->orderBy('keyword', 'asc')
            ->get()->getResultArray();
        $first  = $ids[0]['id'] . '_broad';
        $second = $ids[1]['id'] . '_broad';

        $res  = $this->req()->post('lists/keywords/delete', ['ids' => [$first, $second]]);
        $body = json_decode((string) $res->response()->getBody(), true);

        $this->assertTrue($body['status']);
        $this->assertSame(2, $body['deleted'], 'the tab removes exactly this many rows');

        $active = $this->conn->table('rep_keywords_broad')
            ->where('list_id', $listId)->where('status', 1)->countAllResults();
        $this->assertSame(2, $active, 'and only those two were soft-deleted');

        // Sorted on both sides: deleted_ids is a set, not a sequence — the
        // read-back that produces it has no ORDER BY, so asserting the order
        // would be asserting MyISAM's row order.
        $got = $body['deleted_ids'];
        sort($got);
        $want = [$first, $second];
        sort($want);
        $this->assertSame($want, $got,
            'and it names them, so the tab removes those rows and no others');

        // Deleting them again writes nothing, but they ARE gone, so this is a
        // success. Reporting it as a failure was the bug: with two operators on
        // one account, whoever clicked second got "please retry" over a keyword
        // that had already been removed, and retrying could never succeed while
        // the row sat on their screen.
        $again = $this->req()->post('lists/keywords/delete', ['ids' => [$first, $second]]);
        $body2 = json_decode((string) $again->response()->getBody(), true);
        $this->assertTrue($body2['status'], 'an already-deleted row is still deleted');
        $got2 = $body2['deleted_ids'];
        sort($got2);
        $this->assertSame($want, $got2);

        // A genuine miss is still a failure. This id is well-formed but belongs
        // to no row of this advertiser, so nothing comes back and nothing is
        // removed from the table.
        $miss  = $this->req()->post('lists/keywords/delete', ['ids' => ['99999999_broad']]);
        $body3 = json_decode((string) $miss->response()->getBody(), true);
        $this->assertFalse($body3['status'], 'an id that matches no row is not a success');
    }

    public function testKeywordListDetailIsRefusedForAnotherAdvertisersList(): void
    {
        $this->req()->post('lists/keyword-lists/create', [
            'name' => 'QA Private List', 'type' => 'broad', 'keywords' => 'secret kw',
        ]);
        $listId = (int) $this->conn->table('keyword_lists')
            ->where('advertiser_id', self::ADV)->where('name', 'QA Private List')
            ->get()->getRowArray()['id'];

        $intruder = $this->sess;
        $intruder['adv_id']  = self::ADV + 1;
        $intruder['user_id'] = self::ADV + 1;

        $res = $this->withSession($intruder)->get('lists/keyword-lists/json/' . $listId);
        $res->assertStatus(404);
        $this->assertStringNotContainsString('secret kw', (string) $res->response()->getBody());
    }

    public function testResavingAnEditedListUnchangedKeepsItsDestinationUrls(): void
    {
        // The reason the parser fix had to land first: once the textarea is
        // prefilled, every save rewrites the list. Submitting exactly what the
        // modal renders must be a no-op, urls included.
        $this->req()->post('lists/keyword-lists/create', [
            'name'     => 'QA Roundtrip List',
            'type'     => 'broad',
            'keywords' => "red shoes,https://example.com/red\nblue shoes",
        ]);
        $listId = (int) $this->conn->table('keyword_lists')
            ->where('advertiser_id', self::ADV)->where('name', 'QA Roundtrip List')
            ->get()->getRowArray()['id'];

        // Rebuild exactly what the modal puts in the box.
        $detail = json_decode(
            (string) $this->req()->get('lists/keyword-lists/json/' . $listId)->response()->getBody(),
            true
        );
        $lines = [];
        foreach ($detail['keywords'] as $k) {
            $lines[] = $k['destination_url'] !== ''
                ? $k['keyword'] . ',' . $k['destination_url']
                : $k['keyword'];
        }

        $this->req()->post('lists/keyword-lists/update/' . $listId, [
            'name'     => 'QA Roundtrip List',
            'type'     => 'broad',
            'keywords' => implode("\n", $lines),
        ]);

        $after = $this->conn->table('rep_keywords_broad')
            ->select('keyword, destination_url')
            ->where('list_id', $listId)->where('status', 1)
            ->orderBy('keyword')->get()->getResultArray();

        $this->assertSame(['blue shoes', 'red shoes'], array_column($after, 'keyword'));
        $this->assertSame(
            'https://example.com/red',
            array_column($after, 'destination_url', 'keyword')['red shoes'],
            'an untouched save must not wipe the destination url'
        );
    }

    /* ───────────────── mapping CSV upload ───────────────── */

    /**
     * A campaign of the throwaway advertiser with a targeting row, which is what
     * the mapping upload extends (it never creates targeting from nothing).
     */
    private function makeCampaign(): int
    {
        $this->conn->table('campaign')->insert([
            'advertiser_id'   => self::ADV,
            'name'            => 'QA Map Campaign',
            'status'          => 'A',
            'bid'             => '0.100000',
            'campaign_mode'   => 'cpc',
            'duration'        => 0,
            'linktrust_pixel' => '',
            'site'            => '',
            'pixel'           => '',
            'pixel_conv'      => '',
            'system'          => 0,
            'system_id'       => 0,
            'usonly'          => 0,
        ]);
        $id = (int) $this->conn->insertID();

        $this->conn->table('campaign_targeting')
            ->insert(['campaign_id' => $id, 'json' => json_encode(['countries' => ['US']])]);

        return $id;
    }

    private function tempCsv(string $body): string
    {
        $path = tempnam(sys_get_temp_dir(), 'kwmap') . '.csv';
        file_put_contents($path, $body);
        return $path;
    }

    /** Scoped to the throwaway advertiser so it doesn't depend on session state. */
    private function mappingService(): ListsService
    {
        return new ListsService(new ListsRepository(self::ADV));
    }

    private function keywordsOf(int $listId, string $type): array
    {
        return array_column(
            $this->conn->table('rep_keywords_' . $type)
                ->select('keyword')->where('list_id', $listId)->where('status', 1)
                ->orderBy('keyword')->get()->getResultArray(),
            'keyword'
        );
    }

    private function makeKeywordList(string $name, string $type, string $keywords): int
    {
        $this->req()->post('lists/keyword-lists/create', [
            'name'     => $name,
            'type'     => $type,
            'keywords' => $keywords,
        ]);

        return (int) $this->conn->table('keyword_lists')
            ->where('advertiser_id', self::ADV)->where('name', $name)
            ->get()->getRowArray()['id'];
    }

    public function testMappingUploadAppendsToAnExistingListAndLinksItToTheCampaign(): void
    {
        $listId     = $this->makeKeywordList('QA Map List', 'exact', "old alpha\nold beta");
        $campaignId = $this->makeCampaign();

        // Name/type/campaign on the first row only, one keyword per row after it.
        $csv = $this->tempCsv(
            "Campaign Id,Keyword List Name,Keyword List Id/Name,Keyword Type,Keywords,Destination url\n"
            . $campaignId . ",QA Map List,,Broad,new gamma,https://x.com/\n"
            . ",,,,new delta,\n"
        );
        $res = $this->mappingService()->importKeywordMappingCsv($csv);
        unlink($csv);

        $this->assertSame([], $res['failed'], implode('; ', $res['failed']));

        // The keywords that were already there must survive — this upload appends.
        $this->assertSame(
            ['new delta', 'new gamma', 'old alpha', 'old beta'],
            $this->keywordsOf($listId, 'exact'),
            'existing keywords must not be soft-deleted by an upload'
        );

        // "Broad" in the CSV must not retype a list that already exists as exact.
        $list = $this->conn->table('keyword_lists')->where('id', $listId)->get()->getRowArray();
        $this->assertSame('exact', $list['type']);
        $this->assertSame(1, $this->conn->table('keyword_lists')
            ->where('advertiser_id', self::ADV)->where('name', 'QA Map List')->where('status', 1)
            ->countAllResults(), 'no duplicate list for the same name');

        $targeting = json_decode((string) $this->conn->table('campaign_targeting')
            ->where('campaign_id', $campaignId)->get()->getRowArray()['json'], true);
        $this->assertContains($listId, array_map('intval', $targeting['keyword_lists']['include']));
        $this->assertSame(['US'], $targeting['countries'], 'existing targeting is preserved');
    }

    public function testMappingUploadLinkOnlyRowLeavesTheListsKeywordsAlone(): void
    {
        $listId     = $this->makeKeywordList('QA Link List', 'broad', "keep one\nkeep two");
        $campaignId = $this->makeCampaign();

        // A list id with no keyword means "link this list to that campaign".
        $csv = $this->tempCsv($campaignId . ',,' . $listId . ",,,\n");
        $res = $this->mappingService()->importKeywordMappingCsv($csv);
        unlink($csv);

        $this->assertSame([], $res['failed'], implode('; ', $res['failed']));
        $this->assertSame(['keep one', 'keep two'], $this->keywordsOf($listId, 'broad'));

        $targeting = json_decode((string) $this->conn->table('campaign_targeting')
            ->where('campaign_id', $campaignId)->get()->getRowArray()['json'], true);
        $this->assertContains($listId, array_map('intval', $targeting['keyword_lists']['include']));
    }

    public function testMappingUploadRejectsACampaignOfAnotherAdvertiser(): void
    {
        $listId = $this->makeKeywordList('QA Foreign List', 'broad', 'only mine');

        // Campaign 1 belongs to a real advertiser, not the throwaway one.
        $csv = $this->tempCsv('1,,' . $listId . ",,,\n");
        $res = $this->mappingService()->importKeywordMappingCsv($csv);
        unlink($csv);

        $this->assertStringStartsWith(
            'Keyword list "QA Foreign List", but not linked to Campaign Id 1',
            $res['failed'][0]
        );
        $this->assertSame(['only mine'], $this->keywordsOf($listId, 'broad'));
    }

    /* ───────────────── domains ───────────────── */

    public function testUserCreatesDomainList(): void
    {
        $this->req()->post('lists/domains/create', [
            'name'    => 'QA Domains',
            'domains' => "https://Example.com/path\nfoo.bar.io\nnot a domain",
        ]);
        $list = $this->conn->table('domain_list_names')
            ->where('aid', self::ADV)->where('list_name', 'QA Domains')
            ->get()->getRowArray();
        $this->assertNotNull($list);

        $domains = array_column(
            $this->conn->table('ads_domain_list')->select('domain')->where('list_id', (int) $list['list_id'])->get()->getResultArray(),
            'domain'
        );
        $this->assertContains('example.com', $domains);
        $this->assertContains('foo.bar.io', $domains);
        $this->assertNotContains('not a domain', $domains, 'invalid entries are dropped');
    }

    /* ───────────────── sources ───────────────── */

    public function testUserCreatesSourceListWithStructuredRows(): void
    {
        $this->req()->post('lists/sources/create', [
            'name'    => 'QA Sources',
            'sources' => [
                ['source_id' => '111', 'bid' => '0.25'],
                ['source_id' => '222', 'bid' => '0'],
            ],
        ]);
        $list = $this->conn->table('source_lists')
            ->where('advertiser_id', self::ADV)->where('name', 'QA Sources')
            ->get()->getRowArray();
        $this->assertNotNull($list);

        $rows = $this->conn->table('sources')->where('list_id', (int) $list['id'])->orderBy('source_id')->get()->getResultArray();
        $this->assertCount(2, $rows);
        $this->assertSame(111, (int) $rows[0]['source_id']);
        $this->assertSame('0.250000', (string) $rows[0]['bid']);
        $this->assertSame(222, (int) $rows[1]['source_id']);
        $this->assertSame('0.000000', (string) $rows[1]['bid'], 'bid 0 (block) is allowed');
    }

    public function testCreatingASourceListWithoutRowsIsRejected(): void
    {
        // What the modal submits when only the name was filled in: one spare row
        // with both fields blank. The old message claimed the name was missing.
        $result = $this->req()->post('lists/sources/create', [
            'name'    => 'QA Empty Sources',
            'sources' => [['source_id' => '', 'bid' => '']],
        ]);

        $result->assertRedirect();
        $this->assertSame('At least one Source ID with Bid is required', session()->getFlashdata('errorMsg'));
        $this->assertNull(
            $this->conn->table('source_lists')
                ->where('advertiser_id', self::ADV)->where('name', 'QA Empty Sources')
                ->get()->getRowArray(),
            'no list is created without a source'
        );
    }

    public function testCreatingASourceListWithNoRowsPostedAtAllIsRejected(): void
    {
        // What the browser posts when every row was removed with × — the
        // `sources` key is absent entirely, not blank.
        $this->req()->post('lists/sources/create', ['name' => 'QA No Rows']);

        $this->assertSame('At least one Source ID with Bid is required', session()->getFlashdata('errorMsg'));
        $this->assertNull(
            $this->conn->table('source_lists')
                ->where('advertiser_id', self::ADV)->where('name', 'QA No Rows')
                ->get()->getRowArray()
        );
    }

    public function testASourceBidThatIsNotANumberIsRejectedRatherThanCoercedToZero(): void
    {
        // (float) 'o.25' is 0.0 and a bid of 0 *blocks* the source, so the old
        // cast turned a typo into a block.
        $this->req()->post('lists/sources/create', [
            'name'    => 'QA Bad Bid',
            'sources' => [['source_id' => '111', 'bid' => 'o.25']],
        ]);

        $this->assertSame('Row 1: Bid must be a number (enter 0 to block the source)', session()->getFlashdata('errorMsg'));
        $this->assertNull(
            $this->conn->table('source_lists')
                ->where('advertiser_id', self::ADV)->where('name', 'QA Bad Bid')
                ->get()->getRowArray()
        );
    }

    public function testASourceIdOfZeroIsRejectedRatherThanSilentlyDropped(): void
    {
        // insertSources() skips source_id 0, so it would have been reported as
        // saved and then be missing from the list.
        $this->req()->post('lists/sources/create', [
            'name'    => 'QA Zero Source',
            'sources' => [['source_id' => '0', 'bid' => '0.25']],
        ]);

        $this->assertSame('Row 1: Source must be a Source ID greater than 0', session()->getFlashdata('errorMsg'));
        $this->assertNull(
            $this->conn->table('source_lists')
                ->where('advertiser_id', self::ADV)->where('name', 'QA Zero Source')
                ->get()->getRowArray()
        );
    }

    public function testEditingASourceListCannotClearEveryRow(): void
    {
        $this->req()->post('lists/sources/create', [
            'name'    => 'QA Keep Sources',
            'sources' => [
                ['source_id' => '111', 'bid' => '0.25'],
                ['source_id' => '222', 'bid' => '0'],
            ],
        ]);
        $listId = (int) $this->conn->table('source_lists')
            ->where('advertiser_id', self::ADV)->where('name', 'QA Keep Sources')
            ->get()->getRowArray()['id'];

        // Clearing the Source ID field and saving used to delete every row and
        // report "updated" — with the list still attached to its campaigns, so
        // a source blocked with a bid of 0 quietly became eligible again.
        $result = $this->req()->post('lists/sources/update/' . $listId, [
            'name'    => 'QA Keep Sources',
            'sources' => [['source_id' => '', 'bid' => '']],
        ]);

        $result->assertRedirect();
        $this->assertSame('At least one Source ID with Bid is required', session()->getFlashdata('errorMsg'));
        $this->assertSame(
            2,
            $this->conn->table('sources')->where('list_id', $listId)->countAllResults(),
            'both rows survive a cleared form'
        );
    }

    public function testEditingASourceListSavesTheRowsThatWereLeftInPlace(): void
    {
        $this->req()->post('lists/sources/create', [
            'name'    => 'QA Trim Sources',
            'sources' => [
                ['source_id' => '111', 'bid' => '0.25'],
                ['source_id' => '222', 'bid' => '0'],
            ],
        ]);
        $listId = (int) $this->conn->table('source_lists')
            ->where('advertiser_id', self::ADV)->where('name', 'QA Trim Sources')
            ->get()->getRowArray()['id'];

        // Removing one row of two is a legitimate edit and still replaces the set.
        $this->req()->post('lists/sources/update/' . $listId, [
            'name'    => 'QA Trim Sources',
            'sources' => [['source_id' => '222', 'bid' => '0.75']],
        ]);

        $rows = $this->conn->table('sources')->where('list_id', $listId)->get()->getResultArray();
        $this->assertCount(1, $rows);
        $this->assertSame(222, (int) $rows[0]['source_id']);
        $this->assertSame('0.750000', (string) $rows[0]['bid']);
    }

    public function testAFailedRowWriteLeavesTheExistingSourcesIntact(): void
    {
        $this->req()->post('lists/sources/create', [
            'name'    => 'QA Atomic Sources',
            'sources' => [['source_id' => '111', 'bid' => '0.25']],
        ]);
        $listId = (int) $this->conn->table('source_lists')
            ->where('advertiser_id', self::ADV)->where('name', 'QA Atomic Sources')
            ->get()->getRowArray()['id'];

        // Replacing the rows is DELETE + INSERT, and the delete used to commit on
        // its own — a failed insert left the list with no sources at all while the
        // caller reported an error. Dev MySQL isn't strict (a negative value for
        // `decimal unsigned` is silently clamped), so make this session strict for
        // the duration to get a genuine insert failure.
        $prior = $this->conn->query('SELECT @@SESSION.sql_mode AS m')->getRowArray()['m'];
        $this->conn->query("SET SESSION sql_mode = 'STRICT_ALL_TABLES'");

        try {
            $repo = new ListsRepository(self::ADV, $this->conn);
            $ok   = $repo->updateSourceList($listId, 'QA Atomic Sources', [
                ['source_id' => 222, 'bid' => -5],
            ]);
        } finally {
            $this->conn->query('SET SESSION sql_mode = ?', [$prior]);
        }

        $this->assertFalse($ok, 'the write reports failure');
        $rows = $this->conn->table('sources')->where('list_id', $listId)->get()->getResultArray();
        $this->assertCount(1, $rows, 'the original row is still there');
        $this->assertSame(111, (int) $rows[0]['source_id']);
    }

    public function testRemovingSelectedSourceRowsKeepsTheOnesNotSelected(): void
    {
        $this->req()->post('lists/sources/create', [
            'name'    => 'QA Remove Rows',
            'sources' => [
                ['source_id' => '111', 'bid' => '0.25'],
                ['source_id' => '222', 'bid' => '0.50'],
            ],
        ]);
        $listId = (int) $this->conn->table('source_lists')
            ->where('advertiser_id', self::ADV)->where('name', 'QA Remove Rows')
            ->get()->getRowArray()['id'];

        // deleteRows() read the rows from getSourceList(), which returns the list
        // record only — so "remove 111" used to remove everything.
        $this->req()->post('lists/sources/delete-rows', ['id' => $listId, 'd' => ['111']]);

        $rows = $this->conn->table('sources')->where('list_id', $listId)->get()->getResultArray();
        $this->assertCount(1, $rows, 'only the selected row is removed');
        $this->assertSame(222, (int) $rows[0]['source_id']);
    }

    public function testCreatingAKeywordListWithoutKeywordsNamesOnlyTheMissingField(): void
    {
        // The name was filled in, so the message must not claim otherwise.
        $result = $this->req()->post('lists/keyword-lists/create', [
            'name'     => 'QA No Keywords',
            'type'     => 'broad',
            'keywords' => '',
        ]);

        $result->assertRedirect();
        $this->assertSame('List of keywords is required', session()->getFlashdata('errorMsg'));
        $this->assertNull(
            $this->conn->table('keyword_lists')
                ->where('advertiser_id', self::ADV)->where('name', 'QA No Keywords')
                ->get()->getRowArray()
        );
    }

    public function testAListNameOverTheColumnLimitIsRejected(): void
    {
        // keyword_lists.name / source_lists.name are varchar(50): without the
        // check MySQL truncates and the list is saved under a different name.
        $this->req()->post('lists/keyword-lists/create', [
            'name'     => str_repeat('x', 51),
            'type'     => 'broad',
            'keywords' => 'too long a name',
        ]);

        $this->assertSame('Name cannot be longer than 50 characters', session()->getFlashdata('errorMsg'));
        $this->assertSame(
            0,
            $this->conn->table('keyword_lists')->where('advertiser_id', self::ADV)->countAllResults(),
            'nothing saved under a truncated name'
        );
    }

    public function testUploadKeywordsModalDocumentsTheCsvAndOffersTheTemplate(): void
    {
        // Prod parity for the "Upload Keywords/Campaign" popup: a template
        // download plus the per-column Fields description table.
        $page = $this->req()->get('lists');
        $page->assertStatus(200);

        $html = (string) $page->getBody();
        $this->assertStringContainsString('Download CSV Template', $html);
        $this->assertStringContainsString('lists/keyword-lists/template', $html);
        $this->assertStringContainsString('Fields description', $html);
        $this->assertStringContainsString('Keyword List Id/Name', $html);
    }

    public function testTheUploadTemplateItselfDownloads(): void
    {
        $res = $this->req()->get('lists/keyword-lists/template');
        $res->assertStatus(200);
        $this->assertStringContainsString('Campaign Id', (string) $res->getBody());
    }

    public function testBothKeywordListModalsExplainTheBidField(): void
    {
        // Prod parity: the Bid label carries a "?" hint on Create and Edit alike
        // (one shared view there, two modals here — so assert both).
        $html = (string) $this->req()->get('lists')->response()->getBody();

        // Bootstrap Icons is the only icon font the layout loads; `fa fa-*` (what
        // Prod uses) would render as nothing at all.
        $this->assertSame(
            2,
            substr_count($html, 'bi bi-question-circle lists-bid-tip'),
            'the Bid "?" must render on both the Create and the Edit modal'
        );
        $this->assertStringContainsString('data-bs-toggle="tooltip"', $html);

        // Bootstrap 5 tooltips do nothing without an initialiser, and this page
        // has to supply its own.
        $this->assertStringContainsString('new bootstrap.Tooltip', $html);
    }

    public function testDownloadAllAsZipIsNamedWithADateLikeProd(): void
    {
        // Prod serves keyword_lists_<Y-m-d_H-i-s>.zip; a fixed "keyword_lists.zip"
        // made every export after the first collide in the Downloads folder.
        $this->makeKeywordList('QA Zip List', 'broad', "alpha\nbeta");

        $res = $this->req()->get('lists/keyword-lists/download-all');
        $res->assertStatus(200);

        $disposition = $res->response()->getHeaderLine('Content-Disposition');
        $this->assertMatchesRegularExpression(
            '/^attachment; filename="keyword_lists_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.zip"$/',
            $disposition
        );

        // …and the body is still a real archive holding the list's keywords.
        // Read it off response(), not $res->getBody() — TestResponse forwards
        // getBody() to its DOMParser, which re-serialises the bytes as HTML.
        $tmp = tempnam(sys_get_temp_dir(), 'kwzip_') . '.zip';
        file_put_contents($tmp, (string) $res->response()->getBody());
        $zip = new \ZipArchive();
        $this->assertTrue($zip->open($tmp) === true, 'response body is a readable zip');
        $this->assertSame(1, $zip->numFiles);
        $this->assertStringContainsString('alpha', (string) $zip->getFromIndex(0));
        $zip->close();
        @unlink($tmp);
    }

    /* ───────────────── history ───────────────── */

    public function testHistoryIsRefusedForAnotherAdvertisersList(): void
    {
        // adcenter_log has no account column, so the endpoint's only defence is
        // resolving the record through the advertiser-scoped repository. Without
        // it, any logged-in advertiser could read any list's history — including
        // its name, members, and the internal usernames that edited it — by
        // changing the id in the URL.
        $this->req()->post('lists/domains/create', [
            'name'    => 'QA Private Domains',
            'domains' => 'example.com',
        ]);
        $listId = (int) $this->conn->table('domain_list_names')
            ->where('aid', self::ADV)->where('list_name', 'QA Private Domains')
            ->get()->getRowArray()['list_id'];

        // A different advertiser asks for it. The check runs before the `shorty`
        // connection is opened, so this asserts the refusal on its own.
        $intruder = $this->sess;
        $intruder['adv_id']  = self::ADV + 1;
        $intruder['user_id'] = self::ADV + 1;

        $res = $this->withSession($intruder)->withBodyFormat('json')
            ->post('lists/history/domains/' . $listId, []);

        $res->assertStatus(404);
        $body = json_decode((string) $res->response()->getBody(), true);
        $this->assertFalse($body['success']);
        $this->assertArrayNotHasKey('data', $body, 'no history rows may leak on refusal');
    }

    public function testHistoryIsRefusedForAnIdThatDoesNotExist(): void
    {
        // Same response as "not yours" — the two must be indistinguishable, or
        // the 404 becomes an id oracle.
        $res = $this->req()->withBodyFormat('json')
            ->post('lists/history/domains/987654321', []);

        $res->assertStatus(404);
        $this->assertFalse(json_decode((string) $res->response()->getBody(), true)['success']);
    }

    public function testHistoryRejectsAnUnknownKind(): void
    {
        $res = $this->req()->withBodyFormat('json')
            ->post('lists/history/campaigns/1', []);

        $this->assertFalse(json_decode((string) $res->response()->getBody(), true)['success']);
    }

    public function testHistoryNeverFailsSilently(): void
    {
        // The invariant: the endpoint must never answer with a bare failure. An
        // empty history and a failed load used to be the same empty table, so a
        // broken `shorty` connection looked exactly like "this list has none".
        //
        // This bootstrap wires the Admin DB only, so the `shorty` connect throws
        // here — which is precisely the production failure mode being guarded.
        $this->req()->post('lists/domains/create', [
            'name'    => 'QA History Domains',
            'domains' => 'example.com',
        ]);
        $listId = (int) $this->conn->table('domain_list_names')
            ->where('aid', self::ADV)->where('list_name', 'QA History Domains')
            ->get()->getRowArray()['list_id'];

        $res  = $this->req()->withBodyFormat('json')->post('lists/history/domains/' . $listId, []);
        $body = json_decode((string) $res->response()->getBody(), true);

        $this->assertIsArray($body, 'a failure must still be JSON, not an HTML error page');
        $this->assertArrayHasKey('success', $body);
        if ($body['success'] === false) {
            $this->assertNotEmpty(
                $body['message'] ?? '',
                'a failed history load must carry a reason for the modal to display'
            );
            // The reason must not leak the DB host/user into the browser.
            $this->assertStringNotContainsStringIgnoringCase('denied', $body['message']);
            $this->assertStringNotContainsStringIgnoringCase('mysql', $body['message']);
        } else {
            $this->assertIsArray($body['data']);
        }
    }

    public function testHistoryModalCanShowALoadFailure(): void
    {
        // The modal needs a place to put the reason, and the JS has to use it —
        // otherwise the controller's message is thrown away and the empty table
        // is all the user sees.
        $html = (string) $this->req()->get('lists')->response()->getBody();

        $this->assertStringContainsString('id="listHistoryError"', $html);
        $this->assertStringContainsString('showHistoryError', $html);
    }

    public function testHistoryModalShowsALoadingStateInsteadOfAnEmptyTable(): void
    {
        // The query walks a 17.4M-row table until idx_entity_id_entity ships, so
        // the pending state is on screen for seconds. The table is cleared before
        // the request, so without this the user reads DataTables' "No history
        // entries." — a wrong answer — for the whole wait.
        $html = (string) $this->req()->get('lists')->response()->getBody();

        $this->assertStringContainsString('id="listHistoryLoading"', $html);
        $this->assertStringContainsString('id="listHistoryTableWrap"', $html);
        $this->assertStringContainsString('setHistoryLoading(true)', $html);
        $this->assertStringContainsString('setHistoryLoading(false)', $html);

        // The spinner and the table must never both be visible: one function owns
        // both toggles.
        $this->assertStringContainsString("spinner.classList.toggle('d-none', !isLoading)", $html);
        $this->assertStringContainsString("wrap.classList.toggle('d-none', !!isLoading)", $html);

        // Cleared on failure too, or a dead request leaves the spinner up forever.
        $loadingOff = substr_count($html, 'setHistoryLoading(false)');
        $this->assertGreaterThanOrEqual(2, $loadingOff, 'both the success and catch paths must clear the spinner');
    }

    public function testHistoryIsCappedAndSaysSoRatherThanTruncatingSilently(): void
    {
        // entity='Keywords' alone holds ~376k rows table-wide; a busy list must not
        // stream thousands of rows into the modal, and a cap that looks like the
        // whole history is worse than a slow one. Shares the project-wide event-log
        // cap with Campaign/Creative history rather than defining its own.
        $this->assertTrue(defined('EVENT_LOG_HISTORY_MAX_ROWS'));
        $this->assertSame(500, EVENT_LOG_HISTORY_MAX_ROWS);

        $html = (string) $this->req()->get('lists')->response()->getBody();
        $this->assertStringContainsString('res.truncated', $html);
        $this->assertStringContainsString('most recent changes', $html);
    }

    public function testHistoryModalOffersADateFilter(): void
    {
        // The endpoint has accepted {from,to} since it was written; the popup had
        // no way to send them. Assert the controls and the wiring that uses them.
        $html = (string) $this->req()->get('lists')->response()->getBody();

        // The same drp-dropdown widget the filter bars use, so the popup's date
        // control matches every other page rather than being bare native inputs.
        $this->assertStringContainsString('id="listHistoryRange"', $html);
        $this->assertStringContainsString('data-toggle="custom-daterangepicker"', $html);
        $this->assertStringContainsString('class="drp-dropdown"', $html);
        $this->assertStringContainsString('id="listHistoryClear"', $html);
        // An audit log includes today's rows, so the picker must not cap at
        // yesterday the way the reporting views do.
        $this->assertStringContainsString('data-max="today"', $html);

        // The dates have to reach the request body, not just sit in the DOM. They
        // are read off the apply event because in label mode the input itself
        // holds a range key ('last7'), not a date.
        $this->assertStringContainsString('{ from: from, to: to }', $html);
        $this->assertStringContainsString("on('apply.daterangepicker'", $html);
        $this->assertStringContainsString("picker.startDate.format('YYYY-MM-DD')", $html);

        // Applying a range must REPLACE the rows on screen. Adding without
        // clearing first left the previous rows in place, so Apply looked like
        // it had done nothing at all.
        $this->assertStringContainsString('listHistoryDt.clear();', $html);
        $clearAt = strpos($html, 'listHistoryDt.clear();');
        $addAt   = strpos($html, 'listHistoryDt.row.add(');
        $this->assertNotFalse($addAt);
        $this->assertLessThan($addAt, $clearAt, 'the table must be cleared before rows are added');

        // A slow history query means a second Apply can overtake the first, so
        // stale responses have to be discarded rather than painted.
        $this->assertStringContainsString('seq !== listHistorySeq', $html);
    }

    /* ───────────────── auth ───────────────── */

    public function testUnauthenticatedIsBlocked(): void
    {
        $result = $this->get('lists/keywords?q=x');
        $this->assertContains($result->response()->getStatusCode(), [301, 302, 401]);
    }
}
