<?php

namespace MintyPHP\Tests\Support\Helpers;

use PHPUnit\Framework\TestCase;

/**
 * Contract tests for the `tokenSelectForm()` PHP helper (GR-TEST-001).
 *
 * The helper emits a hidden <select multiple> (source of truth for POST) plus
 * a progressive-enhancement host that the `token-select` runtime component
 * upgrades into an interactive combobox lookup with a flat removable list.
 */
class TokenSelectFormHelperTest extends TestCase
{
    /**
     * @return list<array{id: int, key: string, description: string}>
     */
    private function itemsFixture(): array
    {
        return [
            ['id' => 1, 'key' => 'admin.users.view', 'description' => 'View users'],
            ['id' => 2, 'key' => 'admin.users.edit', 'description' => 'Edit users'],
            ['id' => 3, 'key' => 'billing.invoices.read', 'description' => 'Read invoices'],
        ];
    }

    private function render(callable $call): string
    {
        ob_start();
        try {
            $call();
        } finally {
            $output = ob_get_clean();
        }

        return $output === false ? '' : $output;
    }

    public function testRendersHiddenSelectWithSelectedOptions(): void
    {
        $items = $this->itemsFixture();
        $html = $this->render(function () use ($items): void {
            tokenSelectForm(
                'role-permissions',
                'permission_ids',
                'Assigned permissions',
                'Select permissions',
                $items,
                [1, 3],
                false,
                ['description', 'key']
            );
        });

        self::assertStringContainsString('<select', $html);
        self::assertStringContainsString('name="permission_ids[]"', $html);
        self::assertStringContainsString('multiple', $html);
        self::assertStringContainsString('value="1"', $html);
        self::assertStringContainsString('value="2"', $html);
        self::assertStringContainsString('value="3"', $html);

        // Only ids 1 and 3 must carry `selected`.
        self::assertMatchesRegularExpression('/value="1"[^>]*selected/', $html);
        self::assertMatchesRegularExpression('/value="3"[^>]*selected/', $html);
        self::assertDoesNotMatchRegularExpression('/value="2"[^>]*selected/', $html);
    }

    public function testRendersHostDataAttributeAndCombobox(): void
    {
        $html = $this->render(function (): void {
            tokenSelectForm(
                'role-permissions',
                'permission_ids',
                'Assigned permissions',
                'Select permissions',
                $this->itemsFixture(),
                [1],
                false,
                ['description', 'key']
            );
        });

        self::assertStringContainsString('data-app-component="token-select"', $html);
        self::assertStringContainsString('data-readonly="0"', $html);
        self::assertStringContainsString('data-token-select-id="role-permissions"', $html);
        self::assertStringContainsString('data-token-select-field="permission_ids"', $html);
        // Combobox input + listbox must be present in non-readonly mode.
        self::assertStringContainsString('role="combobox"', $html);
        self::assertStringContainsString('data-token-select-input', $html);
        self::assertStringContainsString('data-token-select-listbox', $html);
        self::assertStringContainsString('id="role-permissions-listbox"', $html);
        self::assertStringContainsString('aria-controls="role-permissions-listbox"', $html);
    }

    public function testReadonlyModeHidesInteractiveAffordances(): void
    {
        $html = $this->render(function (): void {
            tokenSelectForm(
                'role-permissions',
                'permission_ids',
                'Assigned permissions',
                'Select permissions',
                $this->itemsFixture(),
                [1, 2],
                true,
                ['description', 'key']
            );
        });

        self::assertStringContainsString('data-readonly="1"', $html);
        self::assertStringContainsString('app-token-select--readonly', $html);
        // No combobox input, no clear, no remove in readonly.
        self::assertStringNotContainsString('data-token-select-input', $html);
        self::assertStringNotContainsString('data-token-select-clear', $html);
        self::assertStringNotContainsString('data-token-select-remove', $html);
        self::assertStringNotContainsString('role="combobox"', $html);
    }

    public function testSelectedRowsAreFlatAndAlphabeticallySorted(): void
    {
        $items = [
            ['id' => 1, 'key' => 'zulu.task', 'description' => 'Zeta action'],
            ['id' => 2, 'key' => 'alpha.task', 'description' => 'Alpha action'],
            ['id' => 3, 'key' => 'mike.task', 'description' => 'Mike action'],
        ];
        $html = $this->render(function () use ($items): void {
            tokenSelectForm(
                'role-permissions',
                'permission_ids',
                'Assigned permissions',
                'Select permissions',
                $items,
                [1, 2, 3],
                false,
                ['description', 'key']
            );
        });

        // Alphabetical by label: Alpha (id=2) < Mike (id=3) < Zeta (id=1).
        // The `data-token-select-row` attribute only appears on the visible selected rows
        // (not on the hidden <option>s), so its positions reflect the rendered row order.
        $posAlpha = strpos($html, 'data-token-select-row="2"');
        $posMike = strpos($html, 'data-token-select-row="3"');
        $posZeta = strpos($html, 'data-token-select-row="1"');
        self::assertIsInt($posAlpha);
        self::assertIsInt($posMike);
        self::assertIsInt($posZeta);
        self::assertLessThan($posMike, $posAlpha);
        self::assertLessThan($posZeta, $posMike);

        // No grouping markers in output.
        self::assertStringNotContainsString('app-token-select-group', $html);
        self::assertStringNotContainsString('data-token-select-group', $html);
    }

    public function testLabelFallbackChainUsesKeyWhenDescriptionMissing(): void
    {
        $items = [
            ['id' => 7, 'key' => 'admin.users.view', 'description' => ''],
        ];
        $html = $this->render(function () use ($items): void {
            tokenSelectForm(
                'role-permissions',
                'permission_ids',
                'Assigned permissions',
                'Select permissions',
                $items,
                [7],
                false,
                ['description', 'key']
            );
        });

        // The row should display the key, since description is empty.
        self::assertStringContainsString('admin.users.view', $html);
    }

    public function testInvalidItemsRendersErrorCard(): void
    {
        $html = $this->render(function (): void {
            tokenSelectForm(
                'role-permissions',
                'permission_ids',
                'Assigned permissions',
                'Select permissions',
                null,
                [],
                false,
                ['description', 'key']
            );
        });

        self::assertStringContainsString('app-token-select--error', $html);
        // t() returns the input key when no translation is loaded.
        self::assertStringContainsString('token_select.error_invalid_items', $html);
        self::assertStringNotContainsString('data-app-component="token-select"', $html);
    }

    public function testLabelOverridesReplaceDefaultI18nStrings(): void
    {
        $html = $this->render(function (): void {
            tokenSelectForm(
                'custom',
                'tag_ids',
                'Assigned tags',
                'Search tags…',
                $this->itemsFixture(),
                [1],
                false,
                ['description', 'key'],
                null,
                [
                    'emptyState'     => 'No tags selected',
                    'noMatches'      => 'No matching tags',
                    'removeTooltip'  => 'Remove tag',
                    'clearAll'       => 'Drop all tags',
                    'countSuffix'    => 'tags applied',
                ]
            );
        });

        self::assertStringContainsString('data-label-remove="Remove tag"', $html);
        self::assertStringContainsString('data-label-empty-state="No tags selected"', $html);
        self::assertStringContainsString('data-label-no-matches="No matching tags"', $html);
        self::assertStringContainsString('>Drop all tags<', $html);
        self::assertStringContainsString('tags applied', $html);
        // Default i18n key must NOT leak through when an override is provided.
        self::assertStringNotContainsString('token_select.remove', $html);
        self::assertStringNotContainsString('token_select.empty_state_title', $html);
    }

    public function testLabelOverridesForErrorCardReplaceDefault(): void
    {
        $html = $this->render(function (): void {
            tokenSelectForm(
                'custom',
                'tag_ids',
                'Assigned tags',
                'Search tags…',
                null,
                [],
                false,
                ['description', 'key'],
                null,
                ['errorMessage' => 'Tags could not be loaded.']
            );
        });

        self::assertStringContainsString('Tags could not be loaded.', $html);
        self::assertStringNotContainsString('token_select.error_invalid_items', $html);
    }

    public function testAcceptsMinimalItemShape(): void
    {
        // Generic data contract: id + one labelKey is enough; `key` is optional.
        $items = [
            ['id' => 10, 'label' => 'First item'],
            ['id' => 20, 'label' => 'Second item'],
        ];
        $html = $this->render(function () use ($items): void {
            tokenSelectForm(
                'generic',
                'item_ids',
                'Items',
                'Search…',
                $items,
                [10],
                false,
                ['label']
            );
        });

        self::assertStringContainsString('value="10"', $html);
        self::assertStringContainsString('value="20"', $html);
        self::assertMatchesRegularExpression('/value="10"[^>]*selected/', $html);
        self::assertStringContainsString('First item', $html);
        // Without a `key`, hidden options carry an empty `data-token-key` attribute.
        self::assertMatchesRegularExpression('/value="10"[^>]*data-token-key=""/', $html);
    }

    public function testEscapesHostileLabelsAndKeys(): void
    {
        // Protection against GR-SEC-010: every user-facing value must flow through e().
        $items = [
            ['id' => 1, 'key' => '<img src=x onerror=alert(1)>', 'description' => '"><svg onload=alert(1)>'],
        ];
        $html = $this->render(function () use ($items): void {
            tokenSelectForm(
                'hostile',
                'ids',
                'Things',
                'Search…',
                $items,
                [1],
                false,
                ['description']
            );
        });

        // Raw attack payloads must NOT appear unescaped.
        self::assertStringNotContainsString('<img src=x', $html);
        self::assertStringNotContainsString('<svg onload', $html);
        // Escaped forms must be present.
        self::assertStringContainsString('&lt;img src=x', $html);
        self::assertStringContainsString('&lt;svg onload', $html);
    }

    public function testTwoInstancesOnSamePageProduceUniqueListboxIds(): void
    {
        $html = $this->render(function (): void {
            tokenSelectForm('perms', 'permission_ids', 'Permissions', 'Search…', $this->itemsFixture(), [1], false, ['description']);
            tokenSelectForm('roles', 'role_ids', 'Roles', 'Search…', $this->itemsFixture(), [2], false, ['description']);
        });

        self::assertStringContainsString('id="perms-listbox"', $html);
        self::assertStringContainsString('id="roles-listbox"', $html);
        self::assertStringContainsString('aria-controls="perms-listbox"', $html);
        self::assertStringContainsString('aria-controls="roles-listbox"', $html);
        self::assertStringContainsString('data-token-select-id="perms"', $html);
        self::assertStringContainsString('data-token-select-id="roles"', $html);
    }

    public function testServerRendersTranslatedLabelsOnHost(): void
    {
        // The `data-label-*` attributes must carry server-translated strings so
        // JS re-renders (remove, empty-state, no-matches) read them back verbatim
        // instead of rendering i18n key literals.
        $html = $this->render(function (): void {
            tokenSelectForm(
                'token',
                'ids',
                'Assigned things',
                'Search things…',
                $this->itemsFixture(),
                [1],
                false,
                ['description'],
                null,
                [
                    'emptyState'    => 'No things here',
                    'removeTooltip' => 'Drop this thing',
                    'noMatches'     => 'Nothing matches',
                ]
            );
        });

        self::assertStringContainsString('data-label-remove="Drop this thing"', $html);
        self::assertStringContainsString('data-label-empty-state="No things here"', $html);
        self::assertStringContainsString('data-label-no-matches="Nothing matches"', $html);
    }

    public function testEmptyItemsRendersEmptyState(): void
    {
        $html = $this->render(function (): void {
            tokenSelectForm(
                'role-permissions',
                'permission_ids',
                'Assigned permissions',
                'Select permissions',
                [],
                [],
                false,
                ['description', 'key']
            );
        });

        self::assertStringContainsString('data-token-select-empty', $html);
        self::assertStringContainsString('data-app-component="token-select"', $html);
        // Combobox input is still rendered so the user can add items from the empty state.
        self::assertStringContainsString('data-token-select-input', $html);
    }
}
