<?php

namespace Tests\Unit\AdOps;

use App\Services\AdOps\ApiClient;
use App\Services\AdOps\EventLogService;
use App\Services\AdOps\ImportExportService;
use PHPUnit\Framework\TestCase;

/**
 * The Export page's "Download Combined Backup".
 *
 * It streamed the plain campaigns CSV until now, so it and "Download Campaigns
 * CSV" produced byte-identical data under two different filenames — which is
 * what QA reported. These tests pin the shape legacy's download_bak() produced
 * and that this one restores: campaign columns, then creative_<field>_<n> out to
 * the widest campaign's creative count, then the creatives no campaign links.
 *
 * The two fetchers are overridden rather than stubbed at the HTTP layer: they
 * backfill columns straight from the database, and the shaping is what is under
 * test here.
 */
final class CombinedBackupCsvTest extends TestCase
{
    /** @return array<int, array<string, string>> the CSV, parsed */
    private function rows(ImportExportService $service, int $advId = 900): array
    {
        $fp = $service->streamCombinedBackupCsv($advId);
        $this->assertIsResource($fp);

        $rows   = [];
        $header = fgetcsv($fp);
        if ($header === false) {
            fclose($fp);

            return [];
        }
        while (($row = fgetcsv($fp)) !== false) {
            $rows[] = array_combine($header, $row);
        }
        fclose($fp);

        return $rows;
    }

    private function header(ImportExportService $service, int $advId = 900): array
    {
        $fp     = $service->streamCombinedBackupCsv($advId);
        $header = fgetcsv($fp);
        fclose($fp);

        return $header === false ? [] : $header;
    }

    public function testCampaignRowsCarryTheirCreativesOnTheSameRow(): void
    {
        $rows = $this->rows(new BackupFixtureService());

        // 3 campaigns + 1 creative no campaign links.
        $this->assertCount(4, $rows);

        $first = $rows[0];
        $this->assertSame('4001', $first['id']);
        $this->assertSame('Search campaign', $first['name']);
        $this->assertSame('55', $first['creative_id_0']);
        $this->assertSame('Text one', $first['creative_name_0']);
        $this->assertSame('56', $first['creative_id_1']);
        $this->assertSame('Text two', $first['creative_name_1']);
    }

    public function testTheCreativeColumnsAreWidenedToTheBusiestCampaign(): void
    {
        $header = $this->header(new BackupFixtureService());

        // Campaign 4001 links two creatives, so every row carries slots 0 and 1 —
        // and no slot 2, which no campaign needs.
        $this->assertContains('creative_id_0', $header);
        $this->assertContains('creative_display_url_1', $header);
        $this->assertNotContains('creative_id_2', $header);

        // `campaigns` is dropped from the creative field set: on a campaign row the
        // link is the row, and on an orphan row there is nothing to name.
        $this->assertNotContains('creative_campaigns_0', $header);

        // Campaign columns come first, in the exporter's own order.
        $this->assertSame('id', $header[0]);
        $this->assertSame('name', $header[1]);
    }

    public function testACampaignWithFewerCreativesLeavesTheLaterSlotsBlank(): void
    {
        $rows = $this->rows(new BackupFixtureService());

        $this->assertSame('4002', $rows[1]['id']);
        $this->assertSame('57', $rows[1]['creative_id_0']);
        $this->assertSame('', $rows[1]['creative_id_1']);
        $this->assertSame('', $rows[1]['creative_name_1']);
    }

    public function testACampaignWithNoCreativesStillGetsARow(): void
    {
        $rows = $this->rows(new BackupFixtureService());

        $this->assertSame('4003', $rows[2]['id']);
        $this->assertSame('No creatives', $rows[2]['name']);
        $this->assertSame('', $rows[2]['creative_id_0']);
    }

    public function testCreativesNoCampaignUsesAreAppendedInSlotZero(): void
    {
        // Legacy's own behaviour, and the reason this is a *backup*: a creative that
        // is linked nowhere would otherwise be missing from the file entirely.
        $rows   = $this->rows(new BackupFixtureService());
        $orphan = $rows[3];

        $this->assertSame('', $orphan['id'], 'no campaign owns this row');
        $this->assertSame('', $orphan['name']);
        $this->assertSame('58', $orphan['creative_id_0']);
        $this->assertSame('Unused banner', $orphan['creative_name_0']);
        $this->assertSame('', $orphan['creative_id_1']);
    }

    public function testALinkedCreativeIsNotAlsoListedAsAnOrphan(): void
    {
        $rows = $this->rows(new BackupFixtureService());
        $ids  = array_column($rows, 'creative_id_0');

        $this->assertSame(['55', '57', '', '58'], $ids);
    }

    public function testACampaignListingTheSameCreativeTwiceLinksItOnce(): void
    {
        // Campaign_model::get() builds the creatives list with a bare GROUP_CONCAT
        // over a join set its own campaign_type join can multiply, so "55,55" is a
        // shape the API really returns. Two identical creative slots would misreport
        // the campaign and widen every row by one.
        $rows = $this->rows(new DuplicateLinkFixtureService());

        $this->assertSame('55', $rows[0]['creative_id_0']);
        $this->assertArrayNotHasKey('creative_id_1', $rows[0]);
    }

    public function testAnAdvertiserWithNothingToExportGetsAnEmptyHandle(): void
    {
        // sendCsv() turns an empty handle into "No data to export" — a file holding
        // nothing but a header row would download as a silent success.
        $fp = (new EmptyFixtureService())->streamCombinedBackupCsv(900);
        $this->assertIsResource($fp);
        $stat = fstat($fp);
        $this->assertSame(0, $stat['size']);
        fclose($fp);
    }

    public function testTheHeaderDescribesTheFileEvenWithNoCampaigns(): void
    {
        // Legacy took its header from whichever branch ran first, so an advertiser
        // with creatives but no campaigns got a header describing a single creative
        // and no campaign columns at all.
        $rows = $this->rows(new OrphansOnlyFixtureService());

        $this->assertCount(1, $rows);
        $this->assertArrayHasKey('id', $rows[0]);
        $this->assertArrayHasKey('targeting_exclude_states', $rows[0]);
        $this->assertSame('58', $rows[0]['creative_id_0']);
    }
}

/**
 * Three campaigns — 4001 with two creatives, 4002 with one, 4003 with none — and
 * creative 58, which nothing links.
 */
class BackupFixtureService extends ImportExportService
{
    public function __construct()
    {
        parent::__construct(new BackupStubApiClient(), new SilentBackupEventLog());
    }

    public function fetchAllCampaignsForExport($advId)
    {
        return [
            ['id' => 4001, 'name' => 'Search campaign', 'type' => 'text-search', 'creatives' => [55, 56]],
            ['id' => 4002, 'name' => 'Display campaign', 'type' => 'display', 'creatives' => [57]],
            ['id' => 4003, 'name' => 'No creatives', 'type' => 'display', 'creatives' => []],
        ];
    }

    public function fetchAllCreativesForExport()
    {
        return [
            ['id' => 55, 'name' => 'Text one', 'type' => 'text'],
            ['id' => 56, 'name' => 'Text two', 'type' => 'text'],
            ['id' => 57, 'name' => 'Banner', 'type' => 'image'],
            ['id' => 58, 'name' => 'Unused banner', 'type' => 'image'],
        ];
    }
}

class DuplicateLinkFixtureService extends BackupFixtureService
{
    public function fetchAllCampaignsForExport($advId)
    {
        return [['id' => 4001, 'name' => 'Search campaign', 'creatives' => [55, 55]]];
    }

    public function fetchAllCreativesForExport()
    {
        return [['id' => 55, 'name' => 'Text one', 'type' => 'text']];
    }
}

class EmptyFixtureService extends BackupFixtureService
{
    public function fetchAllCampaignsForExport($advId)
    {
        return [];
    }

    public function fetchAllCreativesForExport()
    {
        return [];
    }
}

class OrphansOnlyFixtureService extends BackupFixtureService
{
    public function fetchAllCampaignsForExport($advId)
    {
        return [];
    }

    public function fetchAllCreativesForExport()
    {
        return [['id' => 58, 'name' => 'Unused banner', 'type' => 'image']];
    }
}

class BackupStubApiClient extends ApiClient
{
    public function __construct()
    {
        // No HTTP: the fetchers are overridden.
    }
}

class SilentBackupEventLog extends EventLogService
{
    public function __construct()
    {
        // No DB.
    }

    public function add(array $data)
    {
        return true;
    }
}
