<?php

namespace Tests\Feature;

use App\Models\AdGroupModel;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\FeatureTestTrait;

/**
 * Real-user simulation for the Ad Groups page — the legacy `Ad_Group` port.
 *
 * Drives real HTTP requests through routing, the `adgroup` filter, the
 * controller and AdGroupModel against the dev Admin DB, then asserts the DB
 * side effects. Everything runs under throwaway advertiser ids and is cleaned up
 * in tearDown.
 *
 * Run with the feature bootstrap 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 AdGroupsFlowTest extends CIUnitTestCase
{
    use FeatureTestTrait;

    private const ADV       = 999999981;
    private const OTHER_ADV = 999999982;

    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',
            'role'           => 'admin',
            'enable_adgroup' => 1,
        ];

        $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
    {
        foreach ([self::ADV, self::OTHER_ADV] as $adv) {
            $ids = array_column(
                $this->conn->table('advertiser_adgroups')->select('id')
                    ->where('adv_id', $adv)->get()->getResultArray(),
                'id'
            );
            foreach ($ids as $id) {
                $this->conn->table('advertiser_adgroup_link')->where('adgroup_id', (int) $id)->delete();
            }
            $this->conn->table('advertiser_adgroups')->where('adv_id', $adv)->delete();

            $camps = array_column(
                $this->conn->table('campaign')->select('id')->where('advertiser_id', $adv)->get()->getResultArray(),
                'id'
            );
            foreach ($camps as $cid) {
                $this->conn->table('campaign_creative_link')->where('campaign_id', (int) $cid)->delete();
                // Also drop links pointing AT these campaigns, whichever group
                // owns them. The first run of this suite hit a stray link for a
                // pre-existing campaign under one of these advertiser ids, which
                // failed the link assertions until it was cleared.
                $this->conn->table('advertiser_adgroup_link')->where('campaign_id', (int) $cid)->delete();
            }
            $this->conn->table('campaign')->where('advertiser_id', $adv)->delete();

            $this->conn->table('Advertiser_info')->where('adv_id', $adv)->delete();
        }
    }

    /**
     * The export INNER JOINs Advertiser_info (legacy parity), so a throwaway
     * advertiser needs a row there or the file comes back empty. Only the
     * columns that are NOT NULL without a default need filling.
     */
    private function makeAdvertiserInfo(int $adv, string $name): void
    {
        $this->conn->table('Advertiser_info')->insert([
            'adv_id'       => $adv,
            'Advertiser'   => $name,
            'address2'     => '',
            'account_type' => '',
            'rmx_id'       => '',
            'rmx_io'       => '',
            'rev_share'    => 0,
            'appnexus_id'  => '',
        ]);
    }

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

    private function makeCampaign(string $name, string $status = 'A', int $adv = self::ADV): int
    {
        $this->conn->table('campaign')->insert([
            'advertiser_id' => $adv,
            'name'          => $name,
            'status'        => $status,
        ]);

        return (int) $this->conn->insertID();
    }

    private function groupIdByName(string $name, int $adv = self::ADV): int
    {
        $row = $this->conn->table('advertiser_adgroups')
            ->where('adv_id', $adv)->where('name', $name)
            ->get()->getRowArray();

        return $row ? (int) $row['id'] : 0;
    }

    /** @return list<int> */
    private function linkedCampaigns(int $groupId): array
    {
        return array_map('intval', array_column(
            $this->conn->table('advertiser_adgroup_link')->select('campaign_id')
                ->where('adgroup_id', $groupId)->orderBy('campaign_id')->get()->getResultArray(),
            'campaign_id'
        ));
    }

    /* ─────────────────────────── create ────────────────────────────── */

    public function testUserCreatesAnAdGroupLinkedToCampaigns(): void
    {
        $c1 = $this->makeCampaign('QA AG Camp 1');
        $c2 = $this->makeCampaign('QA AG Camp 2');

        $this->req()->post('ad-groups/create', [
            'name'      => 'QA Group A',
            'status'    => 'A',
            'campaigns' => [$c1, $c2],
        ]);

        $id = $this->groupIdByName('QA Group A');
        $this->assertGreaterThan(0, $id, 'the group row should exist');

        $row = $this->conn->table('advertiser_adgroups')->where('id', $id)->get()->getRowArray();
        $this->assertSame('A', $row['status']);
        $this->assertSame(self::ADV, (int) $row['adv_id']);
        $this->assertSame([$c1, $c2], $this->linkedCampaigns($id));
    }

    public function testCreateStoresTheCampaignsFirstCreativeId(): void
    {
        // Legacy wrote getCreatives($id)[0] into creative_ids, or 0.
        $withCreative = $this->makeCampaign('QA AG Camp Creative');
        $bare         = $this->makeCampaign('QA AG Camp Bare');
        $this->conn->table('campaign_creative_link')->insert(['campaign_id' => $withCreative, 'creative_id' => 777]);
        $this->conn->table('campaign_creative_link')->insert(['campaign_id' => $withCreative, 'creative_id' => 999]);

        $this->req()->post('ad-groups/create', [
            'name'      => 'QA Group Creative',
            'status'    => 'A',
            'campaigns' => [$withCreative, $bare],
        ]);

        $id   = $this->groupIdByName('QA Group Creative');
        $rows = $this->conn->table('advertiser_adgroup_link')
            ->select('campaign_id, creative_ids')->where('adgroup_id', $id)->get()->getResultArray();
        $byCampaign = array_column($rows, 'creative_ids', 'campaign_id');

        $this->assertSame('777', (string) $byCampaign[$withCreative], 'lowest creative id wins');
        $this->assertSame('0', (string) $byCampaign[$bare], 'no creatives stores 0, like legacy');
    }

    public function testCreateWithoutCampaignsIsRejectedRatherThanFatal(): void
    {
        // Legacy had no server-side check: an empty post reached a foreach over
        // an undefined key and 500'd.
        $this->req()->post('ad-groups/create', ['name' => 'QA No Camps', 'status' => 'A']);

        $this->assertSame(0, $this->groupIdByName('QA No Camps'));
    }

    public function testCreateValidatesNameAndStatusWithProdsWording(): void
    {
        $c = $this->makeCampaign('QA AG Camp V');

        $res = $this->req()->post('ad-groups/create', ['name' => '', 'status' => 'A', 'campaigns' => [$c]]);
        $this->assertSame('The ad group name is required', session('errorMsg'));

        $this->req()->post('ad-groups/create', ['name' => 'QA Blank Status', 'status' => '', 'campaigns' => [$c]]);
        $this->assertSame('Status field is empty', session('errorMsg'));
        $this->assertSame(0, $this->groupIdByName('QA Blank Status'));

        $this->req()->post('ad-groups/create', ['name' => 'QA Bad Status', 'status' => 'Z', 'campaigns' => [$c]]);
        $this->assertSame(0, $this->groupIdByName('QA Bad Status'));
    }

    public function testAnotherAdvertisersCampaignCannotBeLinked(): void
    {
        $mine     = $this->makeCampaign('QA AG Mine');
        $foreign  = $this->makeCampaign('QA AG Foreign', 'A', self::OTHER_ADV);

        $this->req()->post('ad-groups/create', [
            'name'      => 'QA Group Scoped',
            'status'    => 'A',
            'campaigns' => [$mine, $foreign],
        ]);

        $id = $this->groupIdByName('QA Group Scoped');
        $this->assertSame([$mine], $this->linkedCampaigns($id), 'a hand-posted foreign campaign id is dropped');
    }

    /* ──────────────────────────── read ─────────────────────────────── */

    public function testTheFeedListsGroupsWithTheirCampaignIdsAndNames(): void
    {
        $c1 = $this->makeCampaign('QA AG Zebra');
        $c2 = $this->makeCampaign('QA AG Alpha');
        $this->req()->post('ad-groups/create', [
            'name' => 'QA Group Feed', 'status' => 'C', 'campaigns' => [$c1, $c2],
        ]);

        $res  = $this->req()->get('ad-groups/json');
        $res->assertStatus(200);
        $body = json_decode((string) $res->response()->getBody(), true);

        $this->assertSame(1, $body['total']);
        $row = $body['data'][0];
        $this->assertSame('QA Group Feed', $row['name']);
        $this->assertSame('C', $row['status']);

        // Ids and names must be in the same order, or the two columns disagree.
        $ids   = explode(',', $row['cids']);
        $names = explode(',', $row['campaigns']);
        $this->assertSame([(string) min($c1, $c2), (string) max($c1, $c2)], $ids);
        $this->assertCount(2, $names);
        $this->assertSame($c1 < $c2 ? 'QA AG Zebra' : 'QA AG Alpha', $names[0]);
    }

    public function testAGroupWithNoCampaignsStillAppearsInTheFeed(): void
    {
        // Created directly: the form requires a campaign, but legacy data has
        // link-less groups and the list must not hide them.
        $this->conn->table('advertiser_adgroups')->insert([
            'name' => 'QA Group Orphan', 'adv_id' => self::ADV, 'status' => 'A',
        ]);

        $body = json_decode((string) $this->req()->get('ad-groups/json')->response()->getBody(), true);
        $this->assertSame(1, $body['total']);
        $this->assertSame('', $body['data'][0]['cids']);
    }

    public function testTheFeedNeverShowsAnotherAdvertisersGroups(): void
    {
        $this->conn->table('advertiser_adgroups')->insert([
            'name' => 'QA Group Theirs', 'adv_id' => self::OTHER_ADV, 'status' => 'A',
        ]);

        $body = json_decode((string) $this->req()->get('ad-groups/json')->response()->getBody(), true);
        $this->assertSame(0, $body['total']);
    }

    /* ──────────────────────────── update ───────────────────────────── */

    public function testEditingRewritesTheCampaignLinks(): void
    {
        $c1 = $this->makeCampaign('QA AG Edit 1');
        $c2 = $this->makeCampaign('QA AG Edit 2');
        $c3 = $this->makeCampaign('QA AG Edit 3');
        $this->req()->post('ad-groups/create', [
            'name' => 'QA Group Edit', 'status' => 'A', 'campaigns' => [$c1, $c2],
        ]);
        $id = $this->groupIdByName('QA Group Edit');

        $this->req()->post('ad-groups/' . $id . '/edit', [
            'name' => 'QA Group Edited', 'status' => 'S', 'campaigns' => [$c3],
        ]);

        $row = $this->conn->table('advertiser_adgroups')->where('id', $id)->get()->getRowArray();
        $this->assertSame('QA Group Edited', $row['name']);
        $this->assertSame('S', $row['status']);
        $this->assertSame([$c3], $this->linkedCampaigns($id));
    }

    public function testEditingKeepsLinksToCampaignsThePickerCannotShow(): void
    {
        // The picker lists Active campaigns only (legacy parity). Legacy deleted
        // every link and re-inserted only what was posted, so a link to a
        // since-paused campaign disappeared on the next save with no warning.
        $active = $this->makeCampaign('QA AG Keep Active');
        $paused = $this->makeCampaign('QA AG Keep Paused');

        $this->req()->post('ad-groups/create', [
            'name' => 'QA Group Keep', 'status' => 'A', 'campaigns' => [$active, $paused],
        ]);
        $id = $this->groupIdByName('QA Group Keep');
        $this->assertSame([min($active, $paused), max($active, $paused)], $this->linkedCampaigns($id));

        // The campaign is paused after the group was built.
        $this->conn->table('campaign')->where('id', $paused)->update(['status' => 'S']);

        // A save that only re-submits what the form could offer.
        $this->req()->post('ad-groups/' . $id . '/edit', [
            'name' => 'QA Group Keep', 'status' => 'A', 'campaigns' => [$active],
        ]);

        $this->assertContains($paused, $this->linkedCampaigns($id), 'the paused campaign link must survive');
        $this->assertContains($active, $this->linkedCampaigns($id));
    }

    public function testAnotherAdvertisersGroupCannotBeEdited(): void
    {
        $this->conn->table('advertiser_adgroups')->insert([
            'name' => 'QA Group Victim', 'adv_id' => self::OTHER_ADV, 'status' => 'A',
        ]);
        $id = $this->groupIdByName('QA Group Victim', self::OTHER_ADV);
        $c  = $this->makeCampaign('QA AG Attacker');

        $this->req()->post('ad-groups/' . $id . '/edit', [
            'name' => 'HACKED', 'status' => 'D', 'campaigns' => [$c],
        ]);

        $row = $this->conn->table('advertiser_adgroups')->where('id', $id)->get()->getRowArray();
        $this->assertSame('QA Group Victim', $row['name'], 'name must be untouched');
        $this->assertSame('A', $row['status']);
        $this->assertSame([], $this->linkedCampaigns($id));
    }

    /* ──────────────────────────── delete ───────────────────────────── */

    public function testDeleteSoftDeletesAndDropsLinks(): void
    {
        $c = $this->makeCampaign('QA AG Del');
        $this->req()->post('ad-groups/create', [
            'name' => 'QA Group Del', 'status' => 'A', 'campaigns' => [$c],
        ]);
        $id = $this->groupIdByName('QA Group Del');

        $res = $this->req()->post('ad-groups/delete/' . $id);
        $this->assertTrue(json_decode((string) $res->response()->getBody(), true)['status']);

        $row = $this->conn->table('advertiser_adgroups')->where('id', $id)->get()->getRowArray();
        $this->assertSame(AdGroupModel::DELETED, $row['status'], 'row is kept, status becomes X');
        $this->assertSame([], $this->linkedCampaigns($id));
    }

    public function testDeleteIsRefusedForAnotherAdvertisersGroup(): void
    {
        // Legacy matched on `id` alone here — advertiser A could delete B's group.
        $this->conn->table('advertiser_adgroups')->insert([
            'name' => 'QA Group NotYours', 'adv_id' => self::OTHER_ADV, 'status' => 'A',
        ]);
        $id = $this->groupIdByName('QA Group NotYours', self::OTHER_ADV);

        $res  = $this->req()->post('ad-groups/delete/' . $id);
        $body = json_decode((string) $res->response()->getBody(), true);
        $this->assertFalse($body['status']);

        $row = $this->conn->table('advertiser_adgroups')->where('id', $id)->get()->getRowArray();
        $this->assertSame('A', $row['status'], 'the other advertiser\'s group must survive');
    }

    public function testBulkDeleteRemovesOnlyTheCallersOwnGroups(): void
    {
        $c = $this->makeCampaign('QA AG Bulk');
        $this->req()->post('ad-groups/create', ['name' => 'QA Bulk 1', 'status' => 'A', 'campaigns' => [$c]]);
        $this->req()->post('ad-groups/create', ['name' => 'QA Bulk 2', 'status' => 'A', 'campaigns' => [$c]]);
        $mine1 = $this->groupIdByName('QA Bulk 1');
        $mine2 = $this->groupIdByName('QA Bulk 2');

        $this->conn->table('advertiser_adgroups')->insert([
            'name' => 'QA Bulk Theirs', 'adv_id' => self::OTHER_ADV, 'status' => 'A',
        ]);
        $theirs = $this->groupIdByName('QA Bulk Theirs', self::OTHER_ADV);

        $res = $this->req()->post('ad-groups/bulk-delete', ['data' => [$mine1, $mine2, $theirs]]);
        $this->assertTrue(json_decode((string) $res->response()->getBody(), true)['status']);

        foreach ([$mine1, $mine2] as $id) {
            $row = $this->conn->table('advertiser_adgroups')->where('id', $id)->get()->getRowArray();
            $this->assertSame(AdGroupModel::DELETED, $row['status']);
        }
        $row = $this->conn->table('advertiser_adgroups')->where('id', $theirs)->get()->getRowArray();
        $this->assertSame('A', $row['status'], 'a foreign id in the batch is skipped, not deleted');
    }

    public function testBulkDeleteRejectsMoreThanAHundred(): void
    {
        $res  = $this->req()->post('ad-groups/bulk-delete', ['data' => range(1, 101)]);
        $body = json_decode((string) $res->response()->getBody(), true);

        $this->assertFalse($body['status']);
        $this->assertSame('Select only 100 ad groups to Delete!', $body['message']);
    }

    public function testBulkDeleteWithNothingSelectedIsRejected(): void
    {
        $body = json_decode(
            (string) $this->req()->post('ad-groups/bulk-delete', ['data' => []])->response()->getBody(),
            true
        );
        $this->assertFalse($body['status']);
    }

    /* ──────────────────────────── export ───────────────────────────── */

    public function testExportIsCsvNamedLikeProdAndOmitsLinklessGroups(): void
    {
        $this->makeAdvertiserInfo(self::ADV, 'QA Export Adv');
        $c = $this->makeCampaign('QA AG Export');
        $this->req()->post('ad-groups/create', [
            'name' => 'QA Group Export', 'status' => 'A', 'campaigns' => [$c],
        ]);
        // A link-less group: legacy's INNER JOINs leave it out of the file.
        $this->conn->table('advertiser_adgroups')->insert([
            'name' => 'QA Group NoLinks', 'adv_id' => self::ADV, 'status' => 'A',
        ]);

        $res = $this->req()->get('ad-groups/export');
        $res->assertStatus(200);
        $this->assertMatchesRegularExpression(
            '/^attachment; filename="ad_groups_export_\d{4}-\d{2}-\d{2}\.csv"$/',
            $res->response()->getHeaderLine('Content-Disposition')
        );

        $csv = (string) $res->response()->getBody();
        $this->assertStringContainsString('Ad Group Name', $csv, 'header row from the column aliases');
        $this->assertStringContainsString('QA Group Export', $csv);
        $this->assertStringNotContainsString('QA Group NoLinks', $csv);
    }

    /* ──────────────────────────── gating ───────────────────────────── */

    public function testTheFeatureFlagGatesTheUrlsNotJustTheNavItem(): void
    {
        // Legacy hid the nav entry but left /ad-groups reachable by anyone.
        $noFlag = $this->sess;
        unset($noFlag['enable_adgroup']);

        foreach (['ad-groups', 'ad-groups/json', 'ad-groups/create', 'ad-groups/export'] as $path) {
            $res = $this->withSession($noFlag)->get($path);
            $this->assertContains(
                $res->response()->getStatusCode(),
                [301, 302, 403],
                $path . ' must be refused without enable_adgroup'
            );
        }
    }

    public function testTheSidebarShowsAdGroupsOnlyWhenEnabled(): void
    {
        $html = (string) $this->req()->get('ad-groups')->response()->getBody();
        $this->assertStringContainsString('Ad Groups', $html);

        $noFlag = $this->sess;
        unset($noFlag['enable_adgroup']);
        $other = (string) $this->withSession($noFlag)->get('/')->response()->getBody();
        $this->assertStringNotContainsString('>Ad Groups<', $other);
    }

    public function testAViewerCannotWrite(): void
    {
        $c = $this->makeCampaign('QA AG Viewer');
        $viewer = $this->sess;
        $viewer['role'] = 'viewer';

        $this->withSession($viewer)->post('ad-groups/create', [
            'name' => 'QA Viewer Group', 'status' => 'A', 'campaigns' => [$c],
        ]);
        $this->assertSame(0, $this->groupIdByName('QA Viewer Group'));
    }

    public function testUnauthenticatedIsBlocked(): void
    {
        $res = $this->get('ad-groups');
        $this->assertContains($res->response()->getStatusCode(), [301, 302, 401, 403]);
    }
}
