<?php

namespace Tests\Unit\Campaign;

use App\Services\Campaign\CampaignMetricTotals;
use PHPUnit\Framework\TestCase;

/**
 * The Campaign Management aggregate ("Total") row.
 *
 * Two properties this pins that are not visible from reading the view:
 *
 *  1. Only the four base metrics are summed. CTR / Avg CPC / Avg CPM are ratios, and a
 *     summed ratio is not a ratio of sums — filling them from the same pass would silently
 *     print an arithmetic mean of the page.
 *  2. The same function totals both upstream shapes — the enriched rows the grid renders and
 *     StatsModel::getBatchCampaignStats()'s per-campaign map. The controller feeds it one or
 *     the other depending on whether the draw fetched the full set, and the footer has to
 *     come out identical either way.
 */
final class CampaignMetricTotalsTest extends TestCase
{
    /** @return list<array<string,mixed>> */
    private function rows(): array
    {
        return [
            [
                'total_impressions' => 1000,
                'total_clicks'      => 120,
                'total_conversions' => 7,
                'total_spent'       => 45.25,
                'avg_ctr'           => 12.0,
                'avg_avgcpc'        => 0.377,
                'avg_avgcpm'        => 45.25,
            ],
            [
                'total_impressions' => 2500500,
                'total_clicks'      => 3400,
                'total_conversions' => 93,
                'total_spent'       => 1954.75,
                'avg_ctr'           => 0.136,
                'avg_avgcpc'        => 0.575,
                'avg_avgcpm'        => 0.782,
            ],
        ];
    }

    public function testSumsTheFourBaseMetrics(): void
    {
        $out = CampaignMetricTotals::sum($this->rows());

        $this->assertSame('2,501,500', $out['total_impressions']);
        $this->assertSame('3,520', $out['total_clicks']);
        $this->assertSame('100', $out['total_conversions']);
        $this->assertSame('2,000.00', $out['total_spent']);
    }

    /** A summed ratio is not a ratio of sums — these columns must stay out of the payload. */
    public function testRatioColumnsAreNotTotalled(): void
    {
        $out = CampaignMetricTotals::sum($this->rows());

        $this->assertSame(
            ['total_impressions', 'total_clicks', 'total_conversions', 'total_spent'],
            array_keys($out)
        );
        foreach (['avg_ctr', 'avg_avgcpc', 'avg_avgcpm', 'bid', 'budget', 'cap', 'pace', 'remaining'] as $field) {
            $this->assertArrayNotHasKey($field, $out);
        }
    }

    /**
     * The keys are simultaneously the row field names, the payload keys and the `data-sort`
     * values the JS looks the footer cells up by. Renaming one without the others silently
     * drops that metric's total.
     */
    public function testTheSummedFieldNamesAreTheOutputKeys(): void
    {
        $out = CampaignMetricTotals::sum($this->rows());

        $this->assertSame(CampaignMetricTotals::SUMMED_FIELDS, array_keys($out));
    }

    /**
     * The stats-batch map is keyed by campaign id and holds only the metric fields; the
     * enriched rows are a list and hold much more. Same totals from both.
     */
    public function testTheStatsMapShapeTotalsIdenticallyToTheRowShape(): void
    {
        $statsMap = [
            4021 => ['total_impressions' => 1000, 'total_clicks' => 120, 'total_conversions' => 7, 'total_spent' => 45.25],
            9910 => ['total_impressions' => 2500500, 'total_clicks' => 3400, 'total_conversions' => 93, 'total_spent' => 1954.75],
        ];

        $this->assertSame(
            CampaignMetricTotals::sum($this->rows()),
            CampaignMetricTotals::sum($statsMap)
        );
    }

    /** Distinguishes "no rows" from "rows that sum to zero" — the JS hides the row on []. */
    public function testNoRowsYieldsAnEmptyPayload(): void
    {
        $this->assertSame([], CampaignMetricTotals::sum([]));
    }

    public function testRowsSummingToZeroStillYieldAPayload(): void
    {
        $out = CampaignMetricTotals::sum([
            ['total_impressions' => 0, 'total_clicks' => 0, 'total_conversions' => 0, 'total_spent' => 0],
        ]);

        $this->assertSame('0', $out['total_impressions']);
        $this->assertSame('0.00', $out['total_spent']);
    }

    /**
     * The controller falls back to this when a draw has rows but the stats batch came back
     * empty — a grid with rows should show a zeroed footer, not lose the row entirely.
     */
    public function testZeroIsAFullPayloadNotAnEmptyOne(): void
    {
        $zero = CampaignMetricTotals::zero();

        $this->assertSame(CampaignMetricTotals::SUMMED_FIELDS, array_keys($zero));
        $this->assertSame('0', $zero['total_impressions']);
        $this->assertSame('0', $zero['total_clicks']);
        $this->assertSame('0', $zero['total_conversions']);
        $this->assertSame('0.00', $zero['total_spent']);
        $this->assertNotSame([], $zero);
    }

    /** Hearst's flight columns use '-' for "not applicable"; it must not poison a sum. */
    public function testNonNumericAndMissingValuesCountAsZero(): void
    {
        $out = CampaignMetricTotals::sum([
            ['total_impressions' => 10, 'total_clicks' => 2, 'total_conversions' => 1, 'total_spent' => 3.5],
            ['total_impressions' => '-', 'total_clicks' => null, 'total_spent' => ''],
            [],
        ]);

        $this->assertSame('10', $out['total_impressions']);
        $this->assertSame('2', $out['total_clicks']);
        $this->assertSame('1', $out['total_conversions']);
        $this->assertSame('3.50', $out['total_spent']);
    }

    /** Numeric strings are what MySQL returns through getResultArray(). */
    public function testNumericStringsAreSummed(): void
    {
        $out = CampaignMetricTotals::sum([
            ['total_impressions' => '1500', 'total_clicks' => '30', 'total_conversions' => '2', 'total_spent' => '12.34'],
        ]);

        $this->assertSame('1,500', $out['total_impressions']);
        $this->assertSame('12.34', $out['total_spent']);
    }

    /**
     * Formatting has to match the grid's own fmtN(): thousands separators, no decimals on
     * the counts, two on Spent. A footer formatted differently from its column reads as a
     * different kind of number.
     */
    public function testFormattingMatchesTheBodyRows(): void
    {
        $out = CampaignMetricTotals::sum([
            ['total_impressions' => 1234567, 'total_clicks' => 8901, 'total_conversions' => 234, 'total_spent' => 9876.5],
        ]);

        $this->assertSame('1,234,567', $out['total_impressions']);
        $this->assertSame('8,901', $out['total_clicks']);
        $this->assertSame('234', $out['total_conversions']);
        $this->assertSame('9,876.50', $out['total_spent']);
    }

    /* ── column count ─────────────────────────────────────────────── */

    /**
     * The base column count, with the roles enumerated so a changed <thead> has to change
     * this number too. The feature test asserts it against the real rendered markup; this
     * one pins the arithmetic on its own.
     */
    public function testColumnCountPerRoleCombination(): void
    {
        $this->assertSame(15, CampaignMetricTotals::columnCount(false, false, false), 'base');
        $this->assertSame(18, CampaignMetricTotals::columnCount(true, false, false), '+ Ad Ops');
        $this->assertSame(25, CampaignMetricTotals::columnCount(false, true, false), '+ mngt');
        $this->assertSame(21, CampaignMetricTotals::columnCount(false, false, true), '+ Hearst');
        $this->assertSame(34, CampaignMetricTotals::columnCount(true, true, true), 'all three');
    }
}
