<?php

namespace Tests\Unit\Filters;

use App\Services\Filters\StickyDateRange;
use DateInterval;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/**
 * StickyDateRange is the single date range every report page shares, so a bug here shows up
 * on eight pages at once. The parity test at the bottom is the important one: it pins the
 * PHP preset windows against the same anchors datepicker.js buildRangeTable() uses, so a
 * change to one side without the other fails here rather than in QA.
 */
final class StickyDateRangeTest extends TestCase
{
    /** @var array{date_start:string,date_end:string} */
    private array $pageDefault;

    protected function setUp(): void
    {
        parent::setUp();
        // The memo is per-request state; a test suite is many "requests" in one process.
        StickyDateRange::resetRequestCache();

        $this->pageDefault = [
            'date_start' => '2000-01-01',
            'date_end'   => '2000-01-07',
        ];
    }

    // ---------------------------------------------------------------- validation

    public function testInvalidDatesFallBackToThePageDefault(): void
    {
        foreach (['', 'not-a-date', '2026/08/28', '28-08-2026', '2026-02-31', '2026-13-01'] as $bad) {
            $out = StickyDateRange::normalize(
                ['date_start' => $bad, 'date_end' => $bad],
                $this->pageDefault
            );
            $this->assertSame($this->pageDefault['date_start'], $out['date_start'], "start for '$bad'");
            $this->assertSame($this->pageDefault['date_end'], $out['date_end'], "end for '$bad'");
        }
    }

    public function testInvertedRangeIsSwapped(): void
    {
        $out = StickyDateRange::normalize(
            ['date_start' => '2026-05-20', 'date_end' => '2026-05-10'],
            $this->pageDefault
        );
        $this->assertSame('2026-05-10', $out['date_start']);
        $this->assertSame('2026-05-20', $out['date_end']);
    }

    public function testOneBadEndpointFallsBackOnlyForThatEndpoint(): void
    {
        $out = StickyDateRange::normalize(
            ['date_start' => '2000-01-03', 'date_end' => 'garbage'],
            $this->pageDefault
        );
        $this->assertSame('2000-01-03', $out['date_start']);
        $this->assertSame($this->pageDefault['date_end'], $out['date_end']);
    }

    // ---------------------------------------------------------------- precedence

    public function testRequestParamsBeatTheCookie(): void
    {
        $request  = $this->makeRequest(
            ['date_start' => '2026-03-01', 'date_end' => '2026-03-10'],
            'POST',
            ['date_start' => '2026-01-01', 'date_end' => '2026-01-05', 'preset' => 'custom']
        );
        $response = $this->makeResponse();

        $out = StickyDateRange::resolve($request, $response, $this->pageDefault);

        $this->assertSame('2026-03-01', $out['date_start']);
        $this->assertSame('2026-03-10', $out['date_end']);
    }

    public function testCookieBeatsThePageDefault(): void
    {
        $request = $this->makeRequest(
            [],
            'GET',
            ['date_start' => '2026-01-01', 'date_end' => '2026-01-05', 'preset' => 'custom']
        );

        $out = StickyDateRange::resolve($request, $this->makeResponse(), $this->pageDefault);

        $this->assertSame('2026-01-01', $out['date_start']);
        $this->assertSame('2026-01-05', $out['date_end']);
    }

    public function testPageDefaultUsedWhenNothingIsStored(): void
    {
        $out = StickyDateRange::resolve($this->makeRequest([], 'GET', null), $this->makeResponse(), $this->pageDefault);

        $this->assertSame($this->pageDefault['date_start'], $out['date_start']);
        $this->assertSame($this->pageDefault['date_end'], $out['date_end']);
    }

    /**
     * The regression this guards: a DataTables draw that omits the dates, or a form that
     * posts them empty, must not overwrite the stored range with the page default.
     */
    public function testBlankOrPartialParamsDoNotClobberTheCookie(): void
    {
        $cookie = ['date_start' => '2026-01-01', 'date_end' => '2026-01-05', 'preset' => 'custom'];

        $cases = [
            'both blank'  => ['date_start' => '', 'date_end' => ''],
            'start only'  => ['date_start' => '2026-06-01'],
            'end only'    => ['date_end' => '2026-06-30'],
            'end invalid' => ['date_start' => '2026-06-01', 'date_end' => 'nope'],
        ];

        foreach ($cases as $label => $params) {
            StickyDateRange::resetRequestCache();
            $response = $this->makeResponse();

            $out = StickyDateRange::resolve($this->makeRequest($params, 'POST', $cookie), $response, $this->pageDefault);

            $this->assertSame('2026-01-01', $out['date_start'], "start, $label");
            $this->assertSame('2026-01-05', $out['date_end'], "end, $label");
            $this->assertSame([], $response->cookiesSet, "no write for: $label");
        }
    }

    /**
     * Precedence must hold for every page's param naming. This is what proves no
     * page-specific knowledge leaked into the store — the caller owns the names.
     */
    #[DataProvider('paramNameProvider')]
    public function testPrecedenceHoldsForEveryParamNaming(string $startName, string $endName): void
    {
        $cookie  = ['date_start' => '2026-01-01', 'date_end' => '2026-01-05', 'preset' => 'custom'];
        $request = $this->makeRequest([$startName => '2026-04-02', $endName => '2026-04-09'], 'POST', $cookie);

        $out = StickyDateRange::resolve(
            $request,
            $this->makeResponse(),
            $this->pageDefault,
            [$startName, $endName]
        );

        $this->assertSame('2026-04-02', $out['date_start']);
        $this->assertSame('2026-04-09', $out['date_end']);

        // The same names posted empty must fall through to the cookie, not the default.
        StickyDateRange::resetRequestCache();
        $out = StickyDateRange::resolve(
            $this->makeRequest([$startName => '', $endName => ''], 'POST', $cookie),
            $this->makeResponse(),
            $this->pageDefault,
            [$startName, $endName]
        );
        $this->assertSame('2026-01-01', $out['date_start']);
        $this->assertSame('2026-01-05', $out['date_end']);
    }

    /** @return list<array{0:string,1:string}> */
    public static function paramNameProvider(): array
    {
        return [
            'dashboard / campaign performance / breakdown' => ['date_start', 'date_end'],
            'pacing report'                                => ['from', 'to'],
            'behavior / podcast report'                     => ['startDate', 'endDate'],
            'categorized inventory'                         => ['start_date', 'end_date'],
        ];
    }

    public function testGetIsReadWhenPostIsAbsent(): void
    {
        $out = StickyDateRange::resolve(
            $this->makeRequest(['date_start' => '2026-07-01', 'date_end' => '2026-07-08'], 'GET', null),
            $this->makeResponse(),
            $this->pageDefault
        );

        $this->assertSame('2026-07-01', $out['date_start']);
        $this->assertSame('2026-07-08', $out['date_end']);
    }

    // ---------------------------------------------------------------- writes

    public function testResolvePersistsAnExplicitRange(): void
    {
        $response = $this->makeResponse();
        StickyDateRange::resolve(
            $this->makeRequest(['date_start' => '2026-03-01', 'date_end' => '2026-03-10'], 'POST', null),
            $response,
            $this->pageDefault
        );

        $this->assertCount(1, $response->cookiesSet);
        $this->assertSame(StickyDateRange::COOKIE_NAME, $response->cookiesSet[0]['name']);
        $this->assertSame(StickyDateRange::TTL_SECONDS, $response->cookiesSet[0]['expire']);

        $payload = json_decode($response->cookiesSet[0]['value'], true);
        $this->assertSame('2026-03-01', $payload['date_start']);
        $this->assertSame('2026-03-10', $payload['date_end']);
        $this->assertArrayHasKey('preset', $payload);
    }

    public function testPersistFalseResolvesWithoutWriting(): void
    {
        $response = $this->makeResponse();
        $out      = StickyDateRange::resolve(
            $this->makeRequest(['date_start' => '2026-03-01', 'date_end' => '2026-03-10'], 'POST', null),
            $response,
            $this->pageDefault,
            ['date_start', 'date_end'],
            null,
            false
        );

        $this->assertSame('2026-03-01', $out['date_start']);
        $this->assertSame([], $response->cookiesSet);
    }

    public function testPeekNeverWrites(): void
    {
        $response = $this->makeResponse();
        StickyDateRange::peek(
            $this->makeRequest(['date_start' => '2026-03-01', 'date_end' => '2026-03-10'], 'GET', null),
            $this->pageDefault
        );

        $this->assertSame([], $response->cookiesSet);
    }

    /** An unchanged range must cost no Set-Cookie header — DataTables redraws re-send it. */
    public function testRememberIsIdempotent(): void
    {
        $stored   = ['date_start' => '2026-03-01', 'date_end' => '2026-03-10', 'preset' => 'custom'];
        $request  = $this->makeRequest([], 'POST', $stored);
        $response = $this->makeResponse();

        StickyDateRange::remember($request, $response, '2026-03-01', '2026-03-10', 'custom');
        $this->assertSame([], $response->cookiesSet, 'identical payload should not re-issue the cookie');

        StickyDateRange::resetRequestCache();
        StickyDateRange::remember($request, $response, '2026-03-01', '2026-03-11', 'custom');
        $this->assertCount(1, $response->cookiesSet, 'a changed payload must be written');
    }

    public function testForgetDeletesTheCookie(): void
    {
        $response = $this->makeResponse();
        StickyDateRange::forget($response);

        $this->assertSame([StickyDateRange::COOKIE_NAME], $response->cookiesDeleted);
    }

    // ---------------------------------------------------------------- presets

    public function testDetectPresetRecognisesEachExactWindow(): void
    {
        foreach ($this->expectedWindows() as $token => $window) {
            // Only assert tokens that can win their own detection race — see the
            // collision test below for the deliberate ambiguities.
            $detected = StickyDateRange::detectPreset($window[0], $window[1]);
            $this->assertSame(
                $window,
                $this->expectedWindows()[$detected],
                "range for '$token' resolved to '$detected', which spans different dates"
            );
        }
    }

    public function testDetectPresetReturnsCustomForAnArbitraryWindow(): void
    {
        $end   = new DateTimeImmutable('yesterday');
        $start = $end->sub(new DateInterval('P4D'));   // 5 days: no preset spans 5

        $this->assertSame(
            StickyDateRange::PRESET_CUSTOM,
            StickyDateRange::detectPreset($start->format('Y-m-d'), $end->format('Y-m-d'))
        );
    }

    /**
     * datepicker.js buildRangeTable() documents that on a month's last day Current Month
     * spans exactly the last 30 days, and on the 1st it spans exactly Last Month, and that
     * the picker "cannot tell those apart". detectPreset() resolves the tie by a fixed
     * order rather than inventing a rule the frontend does not share, so on those days the
     * rolling token wins. Asserted so the ordering is a decision, not an accident.
     */
    public function testDetectPresetTieBreakPrefersTheRollingToken(): void
    {
        $windows = $this->expectedWindows();

        if ($windows['currentmonth'] === $windows['last30']) {
            $this->assertSame(
                'last30',
                StickyDateRange::detectPreset($windows['currentmonth'][0], $windows['currentmonth'][1]),
                'on a month-end day last30 must win the tie against currentmonth'
            );

            return;
        }

        if ($windows['currentmonth'] === $windows['lastMonth']) {
            $this->assertSame(
                'currentmonth',
                StickyDateRange::detectPreset($windows['lastMonth'][0], $windows['lastMonth'][1]),
                'on the 1st currentmonth must win the tie against lastMonth'
            );

            return;
        }

        $this->assertNotSame(
            $windows['currentmonth'],
            $windows['last30'],
            'no collision today — the tie-break ordering is covered on month boundaries'
        );
    }

    public function testCamelCaseAliasesResolveToTheCanonicalToken(): void
    {
        $windows = $this->expectedWindows();

        foreach (['thisMonth' => 'currentmonth', 'currentQuarter' => 'quarter_to_date', 'previousWeekend' => 'lastweekend'] as $alias => $canonical) {
            $out = StickyDateRange::normalize(
                ['date_start' => '2000-01-01', 'date_end' => '2000-01-02', 'preset' => $alias],
                $this->pageDefault
            );
            $this->assertSame($canonical, $out['preset'], "alias '$alias'");
            $this->assertSame($windows[$canonical][0], $out['date_start'], "alias '$alias' start");
            $this->assertSame($windows[$canonical][1], $out['date_end'], "alias '$alias' end");
        }
    }

    // ---------------------------------------------------------------- rolling

    /**
     * The reason a preset token is stored at all. A range picked weeks ago under a 30-day
     * cookie has to come back meaning what its label says, or the picker finds no preset
     * matching the stored dates and calls the user's "Last 7 Days" a Custom Range.
     */
    public function testNamedPresetsRollForwardIgnoringTheirStoredDates(): void
    {
        $windows = $this->expectedWindows();

        foreach (['today', 'yesterday', 'last7', 'last14', 'last30', 'last90', 'currentmonth', 'lastMonth', 'quarter_to_date', 'lastweekend'] as $token) {
            $out = StickyDateRange::normalize(
                [
                    // Deliberately stale dates from a month ago.
                    'date_start' => '2020-01-01',
                    'date_end'   => '2020-01-31',
                    'preset'     => $token,
                ],
                $this->pageDefault
            );

            $this->assertSame($windows[$token][0], $out['date_start'], "$token start");
            $this->assertSame($windows[$token][1], $out['date_end'], "$token end");
            $this->assertSame($token, $out['preset'], "$token preset");
        }
    }

    public function testCustomRangesAreReturnedVerbatim(): void
    {
        $out = StickyDateRange::normalize(
            ['date_start' => '2026-02-03', 'date_end' => '2026-02-19', 'preset' => StickyDateRange::PRESET_CUSTOM],
            $this->pageDefault
        );

        $this->assertSame('2026-02-03', $out['date_start']);
        $this->assertSame('2026-02-19', $out['date_end']);
        $this->assertSame(StickyDateRange::PRESET_CUSTOM, $out['preset']);
    }

    public function testAnUnknownStoredPresetFallsBackToItsStoredDates(): void
    {
        $out = StickyDateRange::normalize(
            ['date_start' => '2026-02-03', 'date_end' => '2026-02-19', 'preset' => 'someRemovedToken'],
            $this->pageDefault
        );

        $this->assertSame('2026-02-03', $out['date_start']);
        $this->assertSame('2026-02-19', $out['date_end']);
    }

    // ---------------------------------------------------------------- labels

    public function testLabelUsesThePresetNameWhenThereIsOne(): void
    {
        $windows = $this->expectedWindows();

        $this->assertSame('Last 7 Days', StickyDateRange::label([
            'date_start' => $windows['last7'][0],
            'date_end'   => $windows['last7'][1],
            'preset'     => 'last7',
        ]));
        $this->assertSame('Current Month', StickyDateRange::label([
            'date_start' => $windows['currentmonth'][0],
            'date_end'   => $windows['currentmonth'][1],
            'preset'     => 'currentmonth',
        ]));
    }

    public function testLabelFormatsDatesForACustomRange(): void
    {
        $this->assertSame(
            'Feb 3, 2026 – Feb 19, 2026',
            StickyDateRange::label([
                'date_start' => '2026-02-03',
                'date_end'   => '2026-02-19',
                'preset'     => StickyDateRange::PRESET_CUSTOM,
            ])
        );
    }

    // ---------------------------------------------------------------- clamping

    public function testClampPreservesTheWindowLength(): void
    {
        $out = StickyDateRange::clampTo(
            ['date_start' => '2026-08-22', 'date_end' => '2026-08-28', 'preset' => 'last7'],
            '2026-08-27'
        );

        $this->assertSame('2026-08-21', $out['date_start']);
        $this->assertSame('2026-08-27', $out['date_end']);
    }

    /**
     * A window sitting wholly beyond the cap still keeps its length — shifting both
     * endpoints back by (end - cap) can never push the start past the cap, so there is no
     * degenerate case to collapse.
     */
    public function testClampShiftsARangeEntirelyPastTheCapKeepingItsLength(): void
    {
        $out = StickyDateRange::clampTo(
            ['date_start' => '2026-09-05', 'date_end' => '2026-09-10', 'preset' => StickyDateRange::PRESET_CUSTOM],
            '2026-08-27'
        );

        $this->assertSame('2026-08-22', $out['date_start']);
        $this->assertSame('2026-08-27', $out['date_end']);
    }

    public function testClampLeavesAnInRangeWindowAlone(): void
    {
        $out = StickyDateRange::clampTo(
            ['date_start' => '2026-08-01', 'date_end' => '2026-08-20', 'preset' => StickyDateRange::PRESET_CUSTOM],
            '2026-08-27'
        );

        $this->assertSame('2026-08-01', $out['date_start']);
        $this->assertSame('2026-08-20', $out['date_end']);
    }

    /**
     * The invariant the clamp exists for: seeding data-end-date past a picker's own maxDate
     * is a documented way to break the widget, so a clamped range must never exceed the cap.
     */
    public function testResolveNeverReturnsAnEndPastTheCap(): void
    {
        $cap = (new DateTimeImmutable('yesterday'))->format('Y-m-d');
        $out = StickyDateRange::resolve(
            $this->makeRequest(
                ['date_start' => (new DateTimeImmutable('today'))->format('Y-m-d'), 'date_end' => (new DateTimeImmutable('today'))->format('Y-m-d')],
                'POST',
                null
            ),
            $this->makeResponse(),
            $this->pageDefault,
            ['date_start', 'date_end'],
            $cap
        );

        $this->assertLessThanOrEqual($cap, $out['date_end']);
    }

    // ---------------------------------------------------------------- JS parity

    /**
     * CONTRACT TEST. Every window below is computed the way datepicker.js
     * buildRangeTable() computes it (public/assets/js/datepicker.js): nowDay = today,
     * windowEnd = yesterday for all rolling/month-to-date/quarter-to-date windows, and
     * lastMonth anchored on the real current month. If the JS range table changes and this
     * mirror does not, the server will seed ranges the picker cannot label — so this test
     * fails first.
     */
    public function testEveryPresetMatchesTheDatepickerJsRangeTable(): void
    {
        foreach ($this->expectedWindows() as $token => $window) {
            $actual = StickyDateRange::expandPreset($token);

            $this->assertIsArray($actual, "expandPreset('$token') returned null");
            $this->assertSame($window[0], $actual['date_start'], "$token start (datepicker.js parity)");
            $this->assertSame($window[1], $actual['date_end'], "$token end (datepicker.js parity)");
        }
    }

    public function testExpandPresetReturnsNullForCustomAndUnknownTokens(): void
    {
        $this->assertNull(StickyDateRange::expandPreset(StickyDateRange::PRESET_CUSTOM));
        $this->assertNull(StickyDateRange::expandPreset('someRemovedToken'));
        $this->assertNull(StickyDateRange::expandPreset(''));
    }

    /**
     * Independent restatement of datepicker.js buildRangeTable(), deliberately written from
     * the JS rather than from the PHP under test.
     *
     * @return array<string, array{0:string,1:string}>
     */
    private function expectedWindows(): array
    {
        $nowDay    = new DateTimeImmutable('today');
        $windowEnd = $nowDay->sub(new DateInterval('P1D'));
        $end       = $windowEnd->format('Y-m-d');

        $rollingStart = static function (int $days) use ($windowEnd): string {
            return $windowEnd->sub(new DateInterval('P' . $days . 'D'))->format('Y-m-d');
        };

        // previousWeekendRange(): walk back to the nearest Sunday inclusive, then a further
        // week if that Sunday is the anchor day itself.
        $sunday = $nowDay;
        while ((int) $sunday->format('w') !== 0) {
            $sunday = $sunday->sub(new DateInterval('P1D'));
        }
        if ($sunday->format('Y-m-d') === $nowDay->format('Y-m-d')) {
            $sunday = $sunday->sub(new DateInterval('P7D'));
        }

        $lastMonthRef = $nowDay->sub(new DateInterval('P1M'));

        $quarterFirstMonth = (intdiv(((int) $windowEnd->format('n')) - 1, 3) * 3) + 1;
        $quarterStart      = $windowEnd->setDate((int) $windowEnd->format('Y'), $quarterFirstMonth, 1);

        return [
            'today'           => [$nowDay->format('Y-m-d'), $nowDay->format('Y-m-d')],
            'yesterday'       => [$end, $end],
            'last7'           => [$rollingStart(6), $end],
            'last14'          => [$rollingStart(13), $end],
            'last30'          => [$rollingStart(29), $end],
            'last90'          => [$rollingStart(89), $end],
            'lastweekend'     => [
                $sunday->sub(new DateInterval('P1D'))->format('Y-m-d'),
                $sunday->format('Y-m-d'),
            ],
            'currentmonth'    => [$windowEnd->format('Y-m-01'), $end],
            'lastMonth'       => [
                $lastMonthRef->format('Y-m-01'),
                $lastMonthRef->modify('last day of this month')->format('Y-m-d'),
            ],
            'quarter_to_date' => [$quarterStart->format('Y-m-d'), $end],
        ];
    }

    // ---------------------------------------------------------------- stubs

    /**
     * Minimal duck-typed IncomingRequest. CI4's is concrete and needs a full bootstrap;
     * the store only calls getPost(), getGet() and getCookie(). Same pattern as
     * CampaignPerformanceFiltersStatusTest.
     *
     * @param array<string,mixed>      $params
     * @param array<string,mixed>|null $cookiePayload decoded contents of the stored cookie
     */
    private function makeRequest(array $params, string $method, ?array $cookiePayload)
    {
        $cookieValue = $cookiePayload === null ? null : json_encode($cookiePayload);

        return new class($params, $method, $cookieValue) extends \CodeIgniter\HTTP\IncomingRequest {
            private array $params;
            private string $methodValue;
            private ?string $cookieValue;

            public function __construct(array $params, string $method, ?string $cookieValue)
            {
                $this->params      = $params;
                $this->methodValue = $method;
                $this->cookieValue = $cookieValue;
                // Skip parent constructor (would require Config\App, server vars, etc).
            }

            public function getMethod(bool $upper = false): string
            {
                return $upper ? strtoupper($this->methodValue) : $this->methodValue;
            }

            public function getGet($index = null, $filter = null, $flags = null)
            {
                $bag = $this->methodValue === 'GET' ? $this->params : [];
                if ($index === null) {
                    return $bag;
                }

                return isset($bag[$index]) ? $bag[$index] : null;
            }

            public function getPost($index = null, $filter = null, $flags = null)
            {
                $bag = $this->methodValue === 'POST' ? $this->params : [];
                if ($index === null) {
                    return $bag;
                }

                return isset($bag[$index]) ? $bag[$index] : null;
            }

            public function getCookie($index = null, $filter = null, $flags = null)
            {
                if ($index === \App\Services\Filters\StickyDateRange::COOKIE_NAME) {
                    return $this->cookieValue;
                }

                return null;
            }
        };
    }

    /** Records setCookie()/deleteCookie() so writes can be counted. */
    private function makeResponse()
    {
        return new class extends \CodeIgniter\HTTP\Response {
            /** @var list<array{name:string,value:string,expire:int}> */
            public array $cookiesSet = [];

            /** @var list<string> */
            public array $cookiesDeleted = [];

            public function __construct()
            {
                // Skip parent constructor (would require Config\App).
            }

            public function setCookie(
                $name,
                $value = '',
                $expire = 0,
                $domain = '',
                $path = '/',
                $prefix = '',
                $secure = null,
                $httponly = null,
                $samesite = null,
            ) {
                $this->cookiesSet[] = [
                    'name'   => (string) $name,
                    'value'  => (string) $value,
                    'expire' => (int) $expire,
                ];

                return $this;
            }

            public function deleteCookie(string $name = '', string $domain = '', string $path = '/', string $prefix = '')
            {
                $this->cookiesDeleted[] = $name;

                return $this;
            }
        };
    }
}
