<?php

namespace Tests\Feature;

use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\SiteURI;
use CodeIgniter\HTTP\UserAgent;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\FeatureTestTrait;
use Config\App;
use Config\Services;
use PHPUnit\Framework\Attributes\DataProvider;

/**
 * Wiring for the Hide/Show Columns dropdown (components/table/column-visibility).
 *
 * Asserted on rendered markup because every failure mode here is silent.
 * column-visibility.js does all of its work from the DOM and gives up quietly
 * on anything it does not recognise:
 *
 *   1. `data-colvis-table="#X"` pointing at an id that is not in the same
 *      document. `initOne` bails at `if (!table || !table.id) return;`, leaving
 *      a Columns button that opens an empty menu. Breakdown renders five of
 *      these from one loop, so a copy/paste that fixes the id on one tab and
 *      not the others looks identical in review.
 *   2. Two widgets sharing a `data-colvis-scope`. They would then read and
 *      write each other's saved layout, which only shows up as columns
 *      reappearing on an unrelated grid.
 *   3. A hideable column with no key. `describeColumns` treats a keyless <th>
 *      as locked, so the column silently drops out of the menu rather than
 *      erroring.
 *   4. The first column not being locked. `applyCss` addresses cells by DOM
 *      position, and the `<td colspan="N">` placeholder rows carry no
 *      `.dataTables_empty` class for its selector to exclude - they only ever
 *      sit at position 1, so locking column 1 is what keeps a rule off them.
 *
 * Also pins the reason this feature is no longer gated: the widget must render
 * for a non-mngt session. The column-level role gates are unaffected and are
 * covered by CampaignTotalsRowColumnsTest.
 */
final class ColumnVisibilityWiringTest extends CIUnitTestCase
{
    use FeatureTestTrait;

    private int $obLevel = 0;

    protected function setUp(): void
    {
        parent::setUp();
        $this->obLevel = ob_get_level();
    }

    protected function tearDown(): void
    {
        while (ob_get_level() > $this->obLevel) {
            ob_end_clean();
        }
        parent::tearDown();
    }

    private function render(string $view, array $data, string $uri): string
    {
        helper(['utils', 'auth']);

        $config = config(App::class);
        Services::injectMock('request', new IncomingRequest(
            $config,
            new SiteURI($config, $uri),
            null,
            new UserAgent()
        ));

        return view($view, $data, ['saveData' => false]);
    }

    private function campaignPerformance(bool $empty = false): string
    {
        return $this->render('campaign_performance/partials/table_section', [
            'cpTableEmpty'   => $empty,
            'tablePeriod'    => 'Aug 1 - Aug 7',
            'cpTableHeaders' => [
                ['label' => 'Campaign', 'class' => 'left-align', 'key' => 'campaign', 'lock' => true],
                ['label' => 'Spend', 'class' => 'right-align', 'key' => 'spend'],
                ['label' => 'Clicks', 'class' => 'right-align', 'key' => 'clicks'],
            ],
            'cpTableRows'   => $empty ? [] : [[['html' => '<a href="#">Alpha</a>'], '$10.00', '3']],
            'cpTableTotals' => $empty ? [] : ['Total', '$10.00', '3'],
        ], 'campaign-performance');
    }

    private function dashboard(): string
    {
        return $this->render('dashboard/partials/table_section', [
            'campaignTableHeaders' => [
                ['label' => 'Campaign', 'class' => 'left-align'],
                ['label' => 'Spend', 'class' => 'right-align', 'kpiId' => 'spend', 'active' => true],
                ['label' => 'Clicks', 'class' => 'right-align', 'kpiId' => 'clicks'],
            ],
            'campaignTableRows'   => [[['html' => '<a href="#">Alpha</a>'], '$10.00', '3']],
            'campaignTableTotals' => ['Total (top 10)', '$10.00', '3'],
            'periodSummary'       => ['current' => 'Aug 1 - Aug 7', 'previous' => ''],
            'dashboardFilters'    => [],
        ], 'dashboard');
    }

    /**
     * @return iterable<string, array{0: string}>
     */
    public static function gridProvider(): iterable
    {
        yield 'campaign performance' => ['campaignPerformance'];
        yield 'dashboard' => ['dashboard'];
    }

    private function html(string $case): string
    {
        return $case === 'dashboard' ? $this->dashboard() : $this->campaignPerformance();
    }

    /**
     * Failure mode 1. `initOne` resolves the table with
     * document.querySelector(data-colvis-table) and returns silently on a miss.
     */
    #[DataProvider('gridProvider')]
    public function testEveryWidgetPointsAtATableInTheSameMarkup(string $case): void
    {
        $html = $this->html($case);

        $found = preg_match_all('/data-colvis-table="#([^"]+)"/', $html, $m);
        $this->assertSame(1, $found, 'expected exactly one Columns widget on ' . $case);

        $this->assertStringContainsString(
            'id="' . $m[1][0] . '"',
            $html,
            $m[1][0] . ' is referenced by data-colvis-table but never rendered'
        );
    }

    /**
     * Failure mode 2. The scope is the localStorage sub-key; two widgets sharing
     * one would silently share a saved layout.
     */
    #[DataProvider('gridProvider')]
    public function testScopesAreUnique(string $case): void
    {
        preg_match_all('/data-colvis-scope="([^"]*)"/', $this->html($case), $m);

        $this->assertNotEmpty($m[1], 'no data-colvis-scope on ' . $case);
        $this->assertSame(
            $m[1],
            array_values(array_unique($m[1])),
            'duplicate colvis scope on ' . $case . ': ' . implode(', ', $m[1])
        );
        $this->assertNotContains('', $m[1], 'an empty colvis scope on ' . $case);
    }

    /**
     * Failure modes 3 and 4, on the <thead> the widget reads.
     */
    #[DataProvider('gridProvider')]
    public function testEveryColumnHasAKeyAndTheFirstIsLocked(string $case): void
    {
        $html = $this->html($case);

        $this->assertSame(
            1,
            preg_match('/<thead.*?<tr>(.*?)<\/tr>/s', $html, $head),
            'no header row on ' . $case
        );

        preg_match_all('/<th\b[^>]*>/s', $head[1], $ths);
        $cells = $ths[0];
        $this->assertCount(3, $cells, 'expected 3 header cells on ' . $case);

        foreach ($cells as $i => $th) {
            $this->assertMatchesRegularExpression(
                '/data-col-key="[^"]+"/',
                $th,
                'header ' . $i . ' on ' . $case . ' has no data-col-key, so the dropdown '
                    . 'silently treats it as locked: ' . $th
            );
        }

        $this->assertStringContainsString(
            'data-col-lock',
            $cells[0],
            'the first column on ' . $case . ' must be locked: applyCss() addresses cells '
                . 'by DOM position and the colspan placeholder row sits at nth-child(1)'
        );
    }

    /**
     * The dashboard is the one grid whose <th> wraps its label in a sort <form>,
     * so the describeColumns() textContent fallback would carry a sort glyph.
     */
    public function testDashboardSuppliesExplicitColumnLabels(): void
    {
        $html = $this->dashboard();

        $this->assertStringContainsString('data-col-label="Spend"', $html);
        $this->assertStringContainsString('data-col-label="Clicks"', $html);
    }

    /**
     * With no rows there is no table to bind to, so the widget must not render -
     * the same rule the search box follows, and for the same reason.
     */
    public function testCampaignPerformanceHidesTheWidgetWhenThereAreNoRows(): void
    {
        $html = $this->campaignPerformance(true);

        $this->assertStringNotContainsString('data-colvis', $html);
    }

    /**
     * Breakdown is the one page that renders the widget from a loop -- five tabs,
     * five tables, five scopes -- so it is the one place a copy/paste can wire a
     * widget to the wrong table or double up a scope. Driven through a real
     * request because the per-tab ids and keys are assembled in the view.
     */
    public function testBreakdownWiresOneWidgetPerTabWithDistinctScopes(): void
    {
        $body = (string) $this->withSession([
            'logged_in' => true,
            'adv_id'    => 15325,
            'user_id'   => 15325,
        ])->get('breakdown-view')->response()->getBody();

        preg_match_all(
            '/data-colvis-table="#([^"]+)"\s+data-colvis-scope="([^"]*)"/s',
            $body,
            $m,
            PREG_SET_ORDER
        );

        $this->assertCount(5, $m, 'expected one Columns widget per Breakdown tab');

        $scopes = [];
        foreach ($m as [$_, $tableId, $scope]) {
            $this->assertStringContainsString(
                'id="' . $tableId . '"',
                $body,
                $tableId . ' is referenced by data-colvis-table but never rendered'
            );
            $this->assertNotSame('', $scope, 'empty colvis scope on #' . $tableId);
            $scopes[] = $scope;
        }

        $this->assertSame(
            $scopes,
            array_values(array_unique($scopes)),
            'two Breakdown tabs share a colvis scope, so they would share a saved layout: '
                . implode(', ', $scopes)
        );
    }

    /**
     * When Ads Showed is the tab whose row identity spans TWO columns - day and
     * hour together are what make a row unique at hour granularity - so an
     * index-0-only lock would leave `hour` hideable.
     */
    public function testBreakdownLocksEveryRowIdentityColumn(): void
    {
        $body = (string) $this->withSession([
            'logged_in' => true,
            'adv_id'    => 15325,
            'user_id'   => 15325,
        ])->get('breakdown-view')->response()->getBody();

        $this->assertSame(
            1,
            preg_match('/<table[^>]*id="whenAdsShowedTable".*?<\/thead>/s', $body, $t),
            'whenAdsShowedTable was not rendered'
        );

        preg_match_all('/<th\b[^>]*>/s', $t[0], $ths);
        $locked = [];
        foreach ($ths[0] as $th) {
            preg_match('/data-col-key="([^"]*)"/', $th, $k);
            $this->assertNotSame('', $k[1] ?? '', 'a Breakdown header has no data-col-key: ' . $th);
            if (str_contains($th, 'data-col-lock')) {
                $locked[] = $k[1];
            }
        }

        $this->assertSame(['day_of_week', 'hour'], $locked);
    }

    /**
     * The point of the change: no mngt gate. Rendered with no session at all,
     * which is the least privileged case there is.
     */
    public function testTheWidgetRendersWithoutAMngtSession(): void
    {
        $this->assertFalse(auth_is_mngt_user(), 'expected a non-mngt session for this test');

        foreach (['campaignPerformance', 'dashboard'] as $case) {
            $this->assertStringContainsString(
                'data-colvis',
                $this->html($case),
                'the Columns widget is gated again on ' . $case
            );
        }
    }
}
