<?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']);
    }

    /* ───────────────── 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');
    }

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

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