<?php

namespace Tests\Feature;

use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\FeatureTestTrait;

/**
 * AP-117 — viewer write-gating and the fail-closed edit decision.
 *
 * Drives real HTTP requests through routing + the editor filter + the real
 * controllers. Asserts the access matrix for viewer / regular / mngt / and the
 * fail-closed missing-role case, plus the adv-19880 creative-edit carve-out.
 *
 * Run with the feature bootstrap + newrelic disabled:
 *   php8.2 -d newrelic.enabled=0 -d newrelic.daemon.dont_launch=3 \
 *     vendor/bin/phpunit --bootstrap tests/feature_bootstrap.php tests/feature
 */
final class EditorGateTest extends CIUnitTestCase
{
    use FeatureTestTrait;

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

    /** The single advertiser whose viewers may still edit creatives (legacy carve-out). */
    private const ADV_19880 = 19880;

    protected function setUp(): void
    {
        parent::setUp();
        // Editor/write routes apply the CSRF filter to non-GET requests; drop it
        // for in-process tests (we exercise the gate, not the token round-trip).
        $filters = config('Filters');
        unset($filters->filters['csrf']);

        $this->obLevel = ob_get_level();
    }

    /**
     * CI4 throws PageNotFoundException while an output buffer is open; the
     * allow-path tests assert that exception, leaving a dangling buffer that
     * PHPUnit flags as "risky". Restore the buffer level we started with.
     */
    protected function tearDown(): void
    {
        while (ob_get_level() > $this->obLevel) {
            ob_end_clean();
        }
        parent::tearDown();
    }

    private int $obLevel = 0;

    private function viewerSession(int $advId = self::ADV): array
    {
        return ['logged_in' => 1, 'adv_id' => $advId, 'user_id' => $advId, 'role' => 'viewer'];
    }

    private function adminSession(): array
    {
        return ['logged_in' => 1, 'adv_id' => self::ADV, 'user_id' => self::ADV, 'role' => 'admin'];
    }

    private function mngtSession(): array
    {
        // mngt user impersonating a viewer-role account — must still edit.
        return ['logged_in' => 1, 'adv_id' => self::ADV, 'user_id' => 1, 'mngtuser' => 'qabot', 'is_superuser' => 1, 'role' => 'viewer'];
    }

    /** No role key at all, not a mngt user — the fail-open regression case. */
    private function missingRoleSession(): array
    {
        return ['logged_in' => 1, 'adv_id' => self::ADV, 'user_id' => self::ADV];
    }

    private function ajaxPost(array $sess, string $uri)
    {
        return $this->withSession($sess)
            ->withHeaders(['X-Requested-With' => 'XMLHttpRequest'])
            ->post($uri);
    }

    /* ── EditorFilter on a write route ───────────────────────── */

    // EditorFilter::deny() answers an AJAX write with 403, not 401: the caller IS
    // authenticated, they just lack the role. It returned 401 until 4f277af
    // ("hide campaign write actions for View users") corrected it; these
    // expectations were left on the old code because the suite was not wired into
    // any phpunit config and so never ran.

    public function testViewerIsBlockedFromBulkWriteRoute(): void
    {
        $r = $this->ajaxPost($this->viewerSession(), 'api/campaign/bulk-pause');
        $r->assertStatus(403); // EditorFilter denies viewers (AJAX → JSON 403)
    }

    public function testMissingRoleIsBlockedFromBulkWriteRoute(): void
    {
        // Fail-closed: an authenticated session with no role must be denied,
        // not silently treated as 'admin'.
        $r = $this->ajaxPost($this->missingRoleSession(), 'api/campaign/bulk-pause');
        $r->assertStatus(403);
    }

    public function testAdminIsNotBlockedByEditorFilter(): void
    {
        // The gate lets an editable role through (controller then runs; whatever
        // it returns, it must NOT be the 403 the filter raises for viewers).
        $r = $this->ajaxPost($this->adminSession(), 'api/campaign/bulk-pause');
        $this->assertNotSame(403, $r->response()->getStatusCode());
    }

    public function testMngtUserIsNotBlockedByEditorFilter(): void
    {
        $r = $this->ajaxPost($this->mngtSession(), 'api/campaign/bulk-pause');
        $this->assertNotSame(403, $r->response()->getStatusCode());
    }

    /* ── Creative edit carve-out ─────────────────────────────── */
    // A BLOCKED request returns a 302 redirect to /creatives before the controller
    // touches the model. An ALLOWED request passes the in-controller gate and
    // reaches the body, which throws PageNotFoundException for the fake creative
    // id — so the thrown exception is itself proof the gate let the request in.

    public function testViewerCannotEditCreatives(): void
    {
        $r = $this->withSession($this->viewerSession())->get('creative/edit/999999999');
        $this->assertContains($r->response()->getStatusCode(), [301, 302]);
    }

    public function testViewerOfAdv19880CanReachCreativeEdit(): void
    {
        // The legacy carve-out: a viewer of adv 19880 passes the gate (unlike any
        // other viewer) and reaches the controller → PageNotFoundException.
        $this->expectException(\CodeIgniter\Exceptions\PageNotFoundException::class);
        $this->withSession($this->viewerSession(self::ADV_19880))->get('creative/edit/999999999');
    }

    public function testAdminCanReachCreativeEdit(): void
    {
        $this->expectException(\CodeIgniter\Exceptions\PageNotFoundException::class);
        $this->withSession($this->adminSession())->get('creative/edit/999999999');
    }

    public function testMngtUserCanReachCreativeEdit(): void
    {
        $this->expectException(\CodeIgniter\Exceptions\PageNotFoundException::class);
        $this->withSession($this->mngtSession())->get('creative/edit/999999999');
    }

    /* ── Carve-out is scoped to the edit FORM only (legacy parity) ── */
    // Legacy store()/update() block ALL viewers with no 19880 exception — only
    // edit() (the GET form) carves out 19880. These guard against the carve-out
    // silently widening to the save/create paths.

    public function testViewerOfAdv19880CannotSaveCreativeUpdate(): void
    {
        $r = $this->ajaxPost($this->viewerSession(self::ADV_19880), 'creative/update/999999999');
        $r->assertStatus(403); // editor filter blocks ALL viewers, incl. 19880
    }

    public function testViewerOfAdv19880CannotStoreCreative(): void
    {
        $r = $this->ajaxPost($this->viewerSession(self::ADV_19880), 'creative/store');
        $r->assertStatus(403);
    }

}
