<?php

namespace Tests\Feature;

use App\Services\Filters\StickyDateRange;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\FeatureTestTrait;
use Config\Services;
use PHPUnit\Framework\Attributes\DataProvider;

/**
 * The regression this whole change exists to prevent: pick a date range on one report, go
 * to another, and the range is gone.
 *
 * Drives real HTTP requests through routing, the auth filter and the real controllers, then
 * asserts against rendered HTML — specifically the data-start-date / data-end-date the
 * server seeds each picker with, which is what makes a page open on the shared range.
 *
 * Cookies are the transport and FeatureTestTrait has no cookie jar, so each test seeds the
 * stored range itself, standing in for the browser sending back a cookie a previous response
 * set. It seeds through the `superglobals` service rather than $_COOKIE directly: CI 4.7
 * snapshots the real superglobals into that service once, so a late write to $_COOKIE is
 * invisible to IncomingRequest::getCookie(). Seeding a real JSON payload also exercises the
 * stored format for real rather than trusting it.
 *
 * Run with the feature config:
 *   php8.2 -d newrelic.enabled=0 -d newrelic.daemon.dont_launch=3 \
 *     vendor/bin/phpunit -c phpunit.feature.xml
 */
final class StickyDateRangeAcrossPagesTest extends CIUnitTestCase
{
    use FeatureTestTrait;

    /** Throwaway advertiser id — never a real account. */
    private const ADV = 999999992;

    /**
     * Pacing / Behavior / Podcast / Categorized Inventory are HearstFilter-gated (404 for
     * anyone else), so reaching them needs an id from HEARST_ADV_IDS. Only the route gate
     * reads it — these tests assert rendered picker markup, not data, so no fixture rows
     * are needed.
     */
    private const HEARST_ADV = 13421;

    /** A deliberately non-default window, so a page falling back cannot coincidentally pass. */
    private const START = '2026-02-03';
    private const END   = '2026-02-19';

    private int $obLevel = 0;

    /** @var array<string,mixed> */
    private array $cookieBackup = [];

    protected function setUp(): void
    {
        parent::setUp();

        // Fresh Response per test. `response` is a shared service, and its cookie store is
        // not reset between tests in one process, so a cookie set by an earlier test's POST
        // would still be attached here and the "an export must not write" assertion below
        // would pass or fail depending on test order. A real request always starts clean.
        Services::injectMock('response', Services::response(null, false));

        // These routes apply the CSRF filter to non-GET requests; drop it for in-process
        // tests, which exercise the range plumbing rather than the token round-trip.
        $filters = config('Filters');
        unset($filters->filters['csrf']);

        $this->cookieBackup = service('superglobals')->getGlobalArray('cookie');
        $this->obLevel      = ob_get_level();
        StickyDateRange::resetRequestCache();
    }

    protected function tearDown(): void
    {
        service('superglobals')->setGlobalArray('cookie', $this->cookieBackup);
        while (ob_get_level() > $this->obLevel) {
            ob_end_clean();
        }
        StickyDateRange::resetRequestCache();
        parent::tearDown();
    }

    /** @return array<string,mixed> */
    private function session(int $advId = self::ADV): array
    {
        return [
            'logged_in' => 1,
            'adv_id'    => $advId,
            'user_id'   => $advId,
            'role'      => 'admin',
        ];
    }

    /**
     * Put a range in the store the way the browser would: as the cookie a previous
     * response set.
     */
    private function storeRange(string $start, string $end, string $preset = 'custom'): void
    {
        service('superglobals')->setCookie(
            StickyDateRange::COOKIE_NAME,
            (string) json_encode([
                'date_start' => $start,
                'date_end'   => $end,
                'preset'     => $preset,
            ])
        );
        StickyDateRange::resetRequestCache();
    }

    /**
     * NOT named get(): FeatureTestTrait already provides get(), and withSession() returns
     * $this — so a private get() here would call itself instead of the trait's, recursing
     * until PHP runs out of memory.
     */
    private function visit(string $uri, int $advId = self::ADV, array $extraSession = [])
    {
        StickyDateRange::resetRequestCache();

        return $this->withSession($this->session($advId) + $extraSession)->get($uri);
    }

    /* ── the range is written where the user applies it ─────────────── */

    public function testApplyingARangeOnTheDashboardStoresIt(): void
    {
        $response = $this->withSession($this->session())->post('dashboard', [
            'dashboard_action' => 'apply',
            'date_start'       => self::START,
            'date_end'         => self::END,
        ]);

        $response->assertCookie(StickyDateRange::COOKIE_NAME);

        $payload = json_decode(
            $response->response()->getCookie(StickyDateRange::COOKIE_NAME)->getValue(),
            true
        );
        $this->assertSame(self::START, $payload['date_start']);
        $this->assertSame(self::END, $payload['date_end']);
    }

    /**
     * The cookie has to survive the PRG redirect. RedirectResponse does not inherit the
     * global response's cookies — only ->withCookies() carries them — so dropping that call
     * would silently make the whole feature look broken while every unit test still passed.
     */
    public function testTheStoredRangeSurvivesThePostRedirect(): void
    {
        $response = $this->withSession($this->session())->post('campaign-performance', [
            'cp_action'  => 'apply_filters',
            'date_start' => self::START,
            'date_end'   => self::END,
        ]);

        $response->assertRedirect();
        $response->assertCookie(StickyDateRange::COOKIE_NAME);
    }

    /* ── the range is read on every other report page ───────────────── */

    /**
     * @return list<array{0:string}>
     */
    public static function stickyPages(): array
    {
        return [
            'dashboard'            => ['dashboard'],
            'campaign performance' => ['campaign-performance'],
            'breakdown view'       => ['breakdown-view'],
            'campaign management'  => ['campaigns'],
        ];
    }

    /**
     * The heart of it: a range stored anywhere is the range every page's picker is seeded
     * with. data-start-date/data-end-date is what datepicker.js reopens the picker on, and
     * what the page-level JS reads back for its own AJAX calls.
     */
    #[DataProvider('stickyPages')]
    public function testEveryReportPageOpensOnTheStoredRange(string $uri): void
    {
        $this->storeRange(self::START, self::END);

        $body = (string) $this->visit($uri)->response()->getBody();

        $this->assertStringContainsString(
            'data-start-date="' . self::START . '"',
            $body,
            "$uri did not seed its picker with the stored start date"
        );
        $this->assertStringContainsString(
            'data-end-date="' . self::END . '"',
            $body,
            "$uri did not seed its picker with the stored end date"
        );
    }

    /**
     * Every in-scope page with the session id needed to reach it. Used by the invariant
     * check below, which has to cover all eight rather than one gating group.
     *
     * @return array<string,array{0:string,1:int}>
     */
    public static function everyStickyPage(): array
    {
        $all = [];
        foreach (self::stickyPages() as $label => $row) {
            $all[$label] = [$row[0], self::ADV];
        }
        foreach (self::hearstStickyPages() as $label => $row) {
            $all[$label] = [$row[0], self::HEARST_ADV];
        }

        return $all;
    }

    /**
     * @return list<array{0:string}>
     */
    public static function hearstStickyPages(): array
    {
        return [
            'pacing report'         => ['pacing-report'],
            'behavior report'       => ['behavior-report'],
            'podcast report'        => ['podcast-report'],
            'categorized inventory' => ['categorized-inventory'],
        ];
    }

    /**
     * Same assertion as above for the HearstFilter-gated reports. Worth its own case: all
     * four had no server-side picker seeding at all before this change — their triggers
     * were hardcoded strings ("Today", "Last 30 Days", "Last 7 Days") and their pickers
     * opened on a data-start preset regardless of the filter in effect.
     */
    #[DataProvider('hearstStickyPages')]
    public function testEveryHearstReportOpensOnTheStoredRange(string $uri): void
    {
        $this->storeRange(self::START, self::END);

        $body = (string) $this->visit($uri, self::HEARST_ADV)->response()->getBody();

        $this->assertStringContainsString(
            'data-start-date="' . self::START . '"',
            $body,
            "$uri did not seed its picker with the stored start date"
        );
        $this->assertStringContainsString(
            'data-end-date="' . self::END . '"',
            $body,
            "$uri did not seed its picker with the stored end date"
        );
    }

    /**
     * Campaign Performance used to reset the range on every fresh GET (AD-2613's
     * DATES_APPLIED_FLASH marker) while keeping its campaign/creative selection. This is
     * the assertion that the date half of that is reversed.
     */
    public function testCampaignPerformanceNoLongerResetsTheRangeOnAFreshGet(): void
    {
        $this->storeRange(self::START, self::END);

        // Two independent GETs: under the old flash marker the second one — with no
        // preceding Apply — was the case that reset to Last 7 Days.
        $this->visit('campaign-performance');
        $body = (string) $this->visit('campaign-performance')->response()->getBody();

        $this->assertStringContainsString('data-start-date="' . self::START . '"', $body);
        $this->assertStringContainsString('data-end-date="' . self::END . '"', $body);
    }

    public function testTheCsvExportUsesTheStoredRange(): void
    {
        $this->storeRange(self::START, self::END);

        $response = $this->visit('campaign-performance/csv');

        $this->assertStringContainsString(
            'campaign-performance-' . self::START . '-to-' . self::END . '.csv',
            (string) $response->response()->getHeaderLine('Content-Disposition')
        );
    }

    /** An export is read-only: it must not be able to author the app-wide range. */
    public function testTheCsvExportDoesNotWriteTheStore(): void
    {
        $this->storeRange(self::START, self::END);

        $this->visit('campaign-performance/csv')->assertCookieMissing(StickyDateRange::COOKIE_NAME);
    }

    /* ── picker invariants ──────────────────────────────────────────── */

    /**
     * Seeding data-end-date past a picker's own maxDate breaks the widget: clickRange()
     * bypasses the clamp, so the following setStartDate/setEndDate drags the selection
     * back. datepicker.js lifts maxDate to today only for pickers whose data-ranges list
     * includes `today`, so this asserts the rule against the real rendered markup — a
     * future edit that drops `today` from a data-ranges list fails here.
     */
    #[DataProvider('everyStickyPage')]
    public function testNoPickerIsSeededPastItsOwnMaxDate(string $uri, int $advId): void
    {
        // A range ending today: harmless where `today` is offered, out of bounds otherwise.
        $today = date('Y-m-d');
        $this->storeRange(date('Y-m-d', strtotime('-6 days')), $today);

        $body  = (string) $this->visit($uri, $advId)->response()->getBody();
        $tags  = [];
        preg_match_all('/<input\b[^>]*data-toggle="custom-daterangepicker"[^>]*>/i', $body, $tags);

        $this->assertNotEmpty($tags[0], "$uri rendered no date picker");

        foreach ($tags[0] as $tag) {
            if (!preg_match('/data-end-date="(\d{4}-\d{2}-\d{2})"/', $tag, $m)) {
                continue; // unseeded picker — nothing to violate
            }

            $offersToday = (bool) preg_match('/data-ranges="[^"]*\btoday\b[^"]*"/', $tag)
                || (bool) preg_match('/data-max="today"/', $tag);
            $cap = $offersToday ? $today : date('Y-m-d', strtotime('yesterday'));

            $this->assertLessThanOrEqual(
                $cap,
                $m[1],
                "$uri seeded a picker with an end date past its own maxDate: " . $tag
            );
        }
    }

    /* ── out-of-scope pickers stay out of scope ─────────────────────── */

    /**
     * The Campaign Management history modal filters an audit log and defaults to "All". A
     * sticky reporting range must not narrow it, so it must stay unseeded.
     *
     * Visited as a mngt user because the modal — like the Actions column that opens it — is
     * only rendered for them. What is under test is that the picker is unseeded, not who can
     * reach it.
     */
    public function testTheHistoryModalPickerIsNotSeeded(): void
    {
        $this->storeRange(self::START, self::END);

        $body = (string) $this->visit('campaigns', self::ADV, [
            'is_superuser' => 1,
            'mngtuser'     => 'qabot',
        ])->response()->getBody();

        $pos = strpos($body, 'camp-history-date-range');
        $this->assertNotFalse($pos, 'the history modal picker is gone — update this test');

        // Look only at the modal's own markup, not the page's reporting picker above it.
        $modal = substr($body, $pos, 1200);
        $this->assertStringNotContainsString('data-start-date=', $modal);
        $this->assertStringNotContainsString('data-end-date=', $modal);
    }
}
