<?php

namespace Tests\Unit\Dashboard;

use App\Services\Dashboard\ReportingPeriod;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/**
 * Previous-period windows and the day-by-day pairing the Dashboard graph draws.
 *
 * Written after a QA report that the Performance Trend "Previous period" line
 * pairs Jul 11 with Jul 3 on a Jul 11-18 range, where Jul 4 was expected. That
 * is the documented behaviour, not a defect: previous is the equal-length block
 * immediately before current, so the offset is the range length (8), not a week.
 * A fixed 7-day shift would put Jul 11 in both windows at once. These tests pin
 * that down so the next person to read the ticket does not "fix" it by hand.
 *
 * ReportingPeriod is pure date arithmetic, so it runs on the lightweight
 * tests/bootstrap.php with no CI4 boot.
 *
 * The pairing itself lives in DashboardChartPresenter::buildKpiCompareJson,
 * which constructs its own StatsService (and therefore DB handles) and cannot be
 * unit-tested. pairings() below mirrors that loop exactly — keep the two in step.
 *
 * @see \App\Services\Dashboard\ReportingPeriod::fromInclusiveDates
 * @see \App\Services\Dashboard\DashboardChartPresenter::buildKpiCompareJson
 */
final class ReportingPeriodTest extends TestCase
{
    /**
     * Current day => previous day, as DashboardChartPresenter walks it:
     * index i of the current window against index i of the previous window.
     *
     * @return array<string,string>
     */
    private function pairings(ReportingPeriod $period): array
    {
        $out    = [];
        $cursor = $period->currentStart->setTime(0, 0, 0);
        $end    = $period->currentEnd->setTime(0, 0, 0);
        $i      = 0;

        while ($cursor->format('Y-m-d') <= $end->format('Y-m-d')) {
            $out[$cursor->format('Y-m-d')] = $period->previousStart
                ->modify('+' . $i . ' days')
                ->format('Y-m-d');
            $i++;
            $cursor = $cursor->modify('+1 day');
        }

        return $out;
    }

    private function days(DateTimeImmutable $start, DateTimeImmutable $end): int
    {
        return (int) $start->diff($end)->days + 1;
    }

    /**
     * The range QA signed off on. 7 days back, so the pairing is week-aligned
     * by coincidence — every current day lands on the same weekday.
     */
    public function testSevenDayRangeShiftsBackSevenDays(): void
    {
        $p = ReportingPeriod::fromInclusiveDates('2026-07-11', '2026-07-17');

        $this->assertSame('2026-07-04', $p->previousStart->format('Y-m-d'));
        $this->assertSame('2026-07-10', $p->previousEnd->format('Y-m-d'));

        $this->assertSame([
            '2026-07-11' => '2026-07-04',
            '2026-07-12' => '2026-07-05',
            '2026-07-13' => '2026-07-06',
            '2026-07-14' => '2026-07-07',
            '2026-07-15' => '2026-07-08',
            '2026-07-16' => '2026-07-09',
            '2026-07-17' => '2026-07-10',
        ], $this->pairings($p));
    }

    /**
     * The range QA raised. 8 days back, NOT 7 — Jul 11 pairs with Jul 3.
     * This is the assertion the bug report argued against; see class docblock.
     */
    public function testEightDayRangeShiftsBackEightDaysNotAWeek(): void
    {
        $p = ReportingPeriod::fromInclusiveDates('2026-07-11', '2026-07-18');

        $this->assertSame('2026-07-03', $p->previousStart->format('Y-m-d'));
        $this->assertSame('2026-07-10', $p->previousEnd->format('Y-m-d'));

        $pairs = $this->pairings($p);

        $this->assertSame('2026-07-03', $pairs['2026-07-11']);
        $this->assertSame('2026-07-04', $pairs['2026-07-12']);
        $this->assertSame('2026-07-10', $pairs['2026-07-18']);

        $this->assertSame([
            '2026-07-11' => '2026-07-03',
            '2026-07-12' => '2026-07-04',
            '2026-07-13' => '2026-07-05',
            '2026-07-14' => '2026-07-06',
            '2026-07-15' => '2026-07-07',
            '2026-07-16' => '2026-07-08',
            '2026-07-17' => '2026-07-09',
            '2026-07-18' => '2026-07-10',
        ], $pairs);
    }

    /**
     * The invariant that rules out the "always shift 7 days" alternative: a day
     * may never appear in both windows, or the KPI-card deltas double-count it.
     */
    #[DataProvider('rangeProvider')]
    public function testPreviousWindowNeverOverlapsCurrent(string $start, string $end): void
    {
        $p = ReportingPeriod::fromInclusiveDates($start, $end);

        $this->assertLessThan(
            $p->currentStart->format('Y-m-d'),
            $p->previousEnd->format('Y-m-d'),
            'previous window must end strictly before the current window begins'
        );
        $this->assertSame(
            $p->currentStart->modify('-1 day')->format('Y-m-d'),
            $p->previousEnd->format('Y-m-d'),
            'previous window must be contiguous with current'
        );
    }

    /**
     * Both series are drawn against the same x-axis, so they must be the same
     * length or the graph silently drops or zero-fills points.
     */
    #[DataProvider('rangeProvider')]
    public function testWindowsAreEqualLength(string $start, string $end): void
    {
        $p = ReportingPeriod::fromInclusiveDates($start, $end);

        $this->assertSame(
            $this->days($p->currentStart, $p->currentEnd),
            $this->days($p->previousStart, $p->previousEnd)
        );
    }

    /**
     * Every current day must map to a real day inside the previous window —
     * this is what guarantees the positional lookup in the presenter can never
     * run off the end.
     */
    #[DataProvider('rangeProvider')]
    public function testEveryPairedDayFallsInsidePreviousWindow(string $start, string $end): void
    {
        $p     = ReportingPeriod::fromInclusiveDates($start, $end);
        $from  = $p->previousStart->format('Y-m-d');
        $to    = $p->previousEnd->format('Y-m-d');
        $pairs = $this->pairings($p);

        $this->assertCount($this->days($p->currentStart, $p->currentEnd), $pairs);

        foreach ($pairs as $cur => $prev) {
            $this->assertGreaterThanOrEqual($from, $prev, "pair for {$cur} fell before the previous window");
            $this->assertLessThanOrEqual($to, $prev, "pair for {$cur} fell after the previous window");
        }
    }

    /**
     * @return array<string,array{string,string}>
     */
    public static function rangeProvider(): array
    {
        return [
            'single day'            => ['2026-07-11', '2026-07-11'],
            'two days'              => ['2026-07-11', '2026-07-12'],
            'one week'              => ['2026-07-11', '2026-07-17'],
            'eight days'            => ['2026-07-11', '2026-07-18'],
            'thirty days'           => ['2026-07-01', '2026-07-30'],
            'crosses month start'   => ['2026-07-01', '2026-07-07'],
            'crosses year start'    => ['2026-01-01', '2026-01-07'],
            'spans a leap day'      => ['2024-02-28', '2024-03-01'],
        ];
    }

    public function testSingleDayComparesAgainstTheDayBefore(): void
    {
        $p = ReportingPeriod::fromInclusiveDates('2026-07-11', '2026-07-11');

        $this->assertTrue($p->isSingleDay());
        $this->assertSame('2026-07-10', $p->previousStart->format('Y-m-d'));
        $this->assertSame('2026-07-10', $p->previousEnd->format('Y-m-d'));
    }

    /**
     * The previous window routinely lands in an earlier month than the current
     * one. PartitionedStatsService picks adv_clicks_<adv>_<YYYYMM> tables from
     * the from/to it is handed, so the boundary has to be exact or a month of
     * previous-period data goes missing without an error.
     */
    public function testPreviousWindowCrossesIntoTheEarlierMonth(): void
    {
        $p = ReportingPeriod::fromInclusiveDates('2026-07-01', '2026-07-07');

        $this->assertSame('2026-06-24', $p->previousStart->format('Y-m-d'));
        $this->assertSame('2026-06-30', $p->previousEnd->format('Y-m-d'));
        $this->assertSame('202606', $p->previousStart->format('Ym'));
    }

    public function testReversedInputIsSwappedRatherThanProducingANegativeWindow(): void
    {
        $p = ReportingPeriod::fromInclusiveDates('2026-07-18', '2026-07-11');

        $this->assertSame('2026-07-11', $p->currentStart->format('Y-m-d'));
        $this->assertSame('2026-07-18', $p->currentEnd->format('Y-m-d'));
        $this->assertSame('2026-07-03', $p->previousStart->format('Y-m-d'));
        $this->assertSame('2026-07-10', $p->previousEnd->format('Y-m-d'));
    }

    /**
     * The window boundaries are what the inclusive `date` >= ? AND `date` <= ?
     * SQL in PartitionedStatsService binds against, so they must cover the whole
     * calendar day at each end.
     */
    public function testBoundariesCoverWholeCalendarDays(): void
    {
        $p = ReportingPeriod::fromInclusiveDates('2026-07-11', '2026-07-18');

        $this->assertSame('00:00:00', $p->currentStart->format('H:i:s'));
        $this->assertSame('23:59:59', $p->currentEnd->format('H:i:s'));
        $this->assertSame('00:00:00', $p->previousStart->format('H:i:s'));
        $this->assertSame('23:59:59', $p->previousEnd->format('H:i:s'));
    }

    /**
     * Date maths that subtracts intervals is where DST bugs hide: in a zone that
     * shifts the clock, a naive subtraction can land on the wrong calendar day.
     * March 8 2026 is the US spring-forward.
     */
    public function testDaylightSavingTransitionDoesNotShiftTheWindow(): void
    {
        $tz = date_default_timezone_get();
        date_default_timezone_set('America/Los_Angeles');

        try {
            $p = ReportingPeriod::fromInclusiveDates('2026-03-09', '2026-03-15');

            $this->assertSame('2026-03-02', $p->previousStart->format('Y-m-d'));
            $this->assertSame('2026-03-08', $p->previousEnd->format('Y-m-d'));
            $this->assertSame(7, $this->days($p->previousStart, $p->previousEnd));

            // The spring-forward day itself must still be exactly one slot wide.
            $pairs = $this->pairings($p);
            $this->assertSame('2026-03-02', $pairs['2026-03-09']);
            $this->assertSame('2026-03-08', $pairs['2026-03-15']);
        } finally {
            date_default_timezone_set($tz);
        }
    }

    public function testSummaryStringsDescribeBothWindows(): void
    {
        $p = ReportingPeriod::fromInclusiveDates('2026-07-11', '2026-07-18');

        $this->assertSame([
            'current'  => 'Jul 11, 2026 – Jul 18, 2026',
            'previous' => 'Jul 3, 2026 – Jul 10, 2026',
        ], $p->getSummaryStrings());
    }
}
