<?php

namespace Tests\Unit\Dashboard;

use App\Services\Dashboard\CampaignBidRepository;
use App\Services\Dashboard\KpiRegistry;
use PHPUnit\Framework\TestCase;

/**
 * COS and CPC, the two formulas AP-128 corrected.
 *
 * Both were wrong in the same way: they reported a number derived from clicks
 * under a label that meant something else. COS was spend / clicks printed with a
 * "%" sign, and CPC was spend / clicks a second time, which put it and eCPC in
 * adjacent table columns showing identical values. These tests pin the corrected
 * meanings so neither can quietly drift back.
 *
 * Both methods under test are pure statics, so they run on the lightweight
 * tests/bootstrap.php with no CI4 boot and no DB.
 */
final class KpiFormulaTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        CampaignBidRepository::clearCache();
    }

    // ---------------------------------------------------------------------
    // COS = spend / revenue x100
    // ---------------------------------------------------------------------

    public function testCosIsSpendOverRevenueAsAPercent(): void
    {
        $k = KpiRegistry::expandTotals([
            'impressions' => 10000,
            'clicks'      => 500,
            'spend'       => 250.0,
            'conversions' => 10,
            'est_revenue' => 1000.0,
        ]);

        // 250 / 1000 = 0.25 -> 25%, not 0.25% and not the old 250/500 = 0.5
        $this->assertSame(25.0, $k['cos']);
    }

    public function testCosIsTheReciprocalOfRoasTimes100(): void
    {
        $k = KpiRegistry::expandTotals([
            'clicks'      => 400,
            'spend'       => 300.0,
            'est_revenue' => 1200.0,
        ]);

        $this->assertSame(4.0, $k['roas']);
        $this->assertEqualsWithDelta(100.0 / $k['roas'], $k['cos'], 0.0000001);
    }

    public function testCosIsZeroWithNoRevenueRatherThanDividingByZero(): void
    {
        $k = KpiRegistry::expandTotals([
            'clicks'      => 500,
            'spend'       => 250.0,
            'est_revenue' => 0.0,
        ]);

        $this->assertSame(0.0, $k['cos']);
    }

    public function testCosNoLongerTracksClicks(): void
    {
        // Same spend and revenue, ten times the clicks: COS must not budge.
        $few = KpiRegistry::expandTotals(['clicks' => 50, 'spend' => 100.0, 'est_revenue' => 400.0]);
        $many = KpiRegistry::expandTotals(['clicks' => 500, 'spend' => 100.0, 'est_revenue' => 400.0]);

        $this->assertSame($few['cos'], $many['cos']);
        $this->assertSame(25.0, $few['cos']);
    }

    // ---------------------------------------------------------------------
    // CPC = the campaign's bid
    // ---------------------------------------------------------------------

    public function testCpcIsTheSuppliedBidNotSpendOverClicks(): void
    {
        $k = KpiRegistry::expandTotals([
            'clicks' => 400,
            'spend'  => 200.0,   // spend/clicks would be 0.50
            'bid'    => 0.75,
        ]);

        $this->assertSame(0.75, $k['cpc']);
        $this->assertSame(0.5, $k['ecpc']);
    }

    public function testCpcAndEcpcAreNoLongerTheSameColumn(): void
    {
        $k = KpiRegistry::expandTotals([
            'clicks' => 1000,
            'spend'  => 320.0,
            'bid'    => 0.9,
        ]);

        $this->assertNotSame($k['cpc'], $k['ecpc']);
    }

    public function testCpcIsZeroWhenNoBidWasSupplied(): void
    {
        // An aggregate context that cannot name a campaign gets 0.0 on purpose —
        // falling back to spend/clicks is what made CPC a duplicate of eCPC.
        $k = KpiRegistry::expandTotals(['clicks' => 400, 'spend' => 200.0]);

        $this->assertSame(0.0, $k['cpc']);
        $this->assertSame(0.5, $k['ecpc']);
    }

    public function testCpcIgnoresTrafficEntirely(): void
    {
        $noTraffic = KpiRegistry::expandTotals(['clicks' => 0, 'spend' => 0.0, 'bid' => 1.25]);

        $this->assertSame(1.25, $noTraffic['cpc']);
    }

    // ---------------------------------------------------------------------
    // Aggregate bid
    // ---------------------------------------------------------------------

    public function testWeightedAverageWeightsBidsByClicks(): void
    {
        // 900 clicks at $0.10 and 100 at $1.00 -> (90 + 100) / 1000 = $0.19.
        // A plain mean would say $0.55 and overstate it nearly threefold.
        $avg = CampaignBidRepository::weightedAverage(
            [1 => 0.10, 2 => 1.00],
            [1 => 900.0, 2 => 100.0]
        );

        $this->assertEqualsWithDelta(0.19, $avg, 0.0000001);
    }

    public function testWeightedAverageIgnoresCampaignsWithNoClicks(): void
    {
        // Campaign 2 is paused at a high bid; it must not drag the account number up.
        $avg = CampaignBidRepository::weightedAverage(
            [1 => 0.20, 2 => 5.00],
            [1 => 750.0, 2 => 0.0]
        );

        $this->assertSame(0.20, $avg);
    }

    public function testWeightedAverageFallsBackToAPlainMeanWhenNothingHasClicks(): void
    {
        // A future date range has no traffic to weight by, but the bids on file are
        // still the honest answer — 0.00 would read as a real bid of zero.
        $avg = CampaignBidRepository::weightedAverage(
            [1 => 0.40, 2 => 0.60],
            [1 => 0.0, 2 => 0.0]
        );

        $this->assertSame(0.50, $avg);
    }

    public function testWeightedAverageOfNoCampaignsIsZero(): void
    {
        $this->assertSame(0.0, CampaignBidRepository::weightedAverage([], []));
    }

    public function testWeightedAverageToleratesMissingClickEntries(): void
    {
        // A campaign with a bid but no row in the stats map must not throw.
        $avg = CampaignBidRepository::weightedAverage(
            [1 => 0.30, 7 => 0.90],
            [1 => 200.0]
        );

        $this->assertSame(0.30, $avg);
    }
}
