<?php

namespace Tests\Feature;

use App\Models\User;
use Tests\TestCase;

/**
 * The accessibility exit gate, run against the HTML the portal actually
 * renders for a real member rather than against the templates.
 *
 * These are the checks that can be made from markup alone: accessible names,
 * form labels, image alternatives, language, landmarks and heading order. They
 * are a floor, not a ceiling — an automated pass is not WCAG conformance, and
 * contrast, focus order and screen-reader behaviour still need a person and a
 * browser.
 *
 *   DB_CONNECTION=pgsql DB_DATABASE=oc_sport_test ./vendor/bin/phpunit --filter PortalAccessibilityTest
 */
class PortalAccessibilityTest extends TestCase
{
    private const SCREENS = [
        'portal.home', 'portal.training', 'portal.payments', 'portal.academy',
        'portal.account', 'portal.notifications', 'portal.pass', 'portal.dues',
        'portal.documents', 'portal.privacy', 'portal.requests.create',
        'portal.preferences',
    ];

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

        if (config('database.default') !== 'pgsql') {
            $this->markTestSkipped('Needs a restored Postgres tenant.');
        }
    }

    private function member(): User
    {
        $resolver = app(\App\Domain\Identity\Services\GuardianResolver::class);

        foreach (User::withoutGlobalScopes()->whereNotNull('person_id')->limit(200)->get() as $candidate) {
            if ($resolver->participantIdsFor($candidate) !== []) {
                return $candidate;
            }
        }

        $this->markTestSkipped('No member account on this tenant.');
    }

    /** @return array<int, string> */
    private function html(string $route): string
    {
        return $this->actingAs($this->member())->get(route($route))->getContent();
    }

    public function test_every_screen_declares_its_language_and_matching_direction(): void
    {
        foreach (self::SCREENS as $route) {
            $html = $this->html($route);

            preg_match('/<html[^>]*lang="([^"]*)"/', $html, $lang);
            preg_match('/<html[^>]*dir="([^"]*)"/', $html, $dir);

            $this->assertNotEmpty($lang[1] ?? '', "{$route}: no lang attribute");

            // An Arabic document marked ltr, or an English one marked rtl,
            // tells a screen reader two contradictory things about one text.
            $expected = in_array($lang[1], ['ar', 'he', 'fa', 'ur'], true) ? 'rtl' : 'ltr';
            $this->assertSame($expected, $dir[1] ?? '', "{$route}: dir does not match lang");
        }
    }

    public function test_no_image_is_missing_an_alt_attribute(): void
    {
        foreach (self::SCREENS as $route) {
            preg_match_all('/<img\b[^>]*>/i', $this->html($route), $images);

            foreach ($images[0] as $img) {
                $this->assertMatchesRegularExpression(
                    '/\balt\s*=/i',
                    $img,
                    "{$route}: <img> with no alt — " . substr($img, 0, 90)
                );
            }
        }
    }

    public function test_every_icon_only_control_has_an_accessible_name(): void
    {
        foreach (self::SCREENS as $route) {
            $html = $this->html($route);

            preg_match_all('/<(a|button)\b[^>]*>(.*?)<\/\1>/is', $html, $controls, PREG_SET_ORDER);

            foreach ($controls as [$whole, $tag, $inner]) {
                // Text content with all markup removed. A control whose only
                // child is an <svg> has none, so it needs a name from
                // aria-label, aria-labelledby, title or visually hidden text.
                $text = trim(preg_replace('/\s+/u', ' ', strip_tags($inner)));

                if ($text !== '') {
                    continue;
                }

                $named = preg_match('/aria-label\s*=\s*"[^"]+"/i', $whole)
                    || preg_match('/aria-labelledby\s*=\s*"[^"]+"/i', $whole)
                    || preg_match('/\btitle\s*=\s*"[^"]+"/i', $whole);

                $this->assertTrue(
                    $named,
                    "{$route}: <{$tag}> with no text and no accessible name — " . substr(preg_replace('/\s+/', ' ', $whole), 0, 130)
                );
            }
        }
    }

    public function test_every_form_control_has_a_label(): void
    {
        foreach (self::SCREENS as $route) {
            $html = $this->html($route);

            preg_match_all('/<(input|select|textarea)\b[^>]*>/i', $html, $fields, PREG_SET_ORDER);

            // Every `for` on the page, so an id can be matched to its label.
            preg_match_all('/<label\b[^>]*\bfor\s*=\s*"([^"]+)"/i', $html, $labels);
            $labelled = array_flip($labels[1]);

            foreach ($fields as [$field, $tag]) {
                // Hidden fields and Livewire's own machinery carry no UI.
                if (preg_match('/type\s*=\s*"(hidden|csrf)"/i', $field)) {
                    continue;
                }

                preg_match('/\bid\s*=\s*"([^"]+)"/i', $field, $id);

                $ok = (isset($id[1]) && isset($labelled[$id[1]]))
                    || preg_match('/aria-label\s*=\s*"[^"]+"/i', $field)
                    || preg_match('/aria-labelledby\s*=\s*"[^"]+"/i', $field)
                    // A checkbox wrapped inside its own <label> is labelled by
                    // the surrounding element rather than by `for`.
                    || preg_match('/type\s*=\s*"(checkbox|radio)"/i', $field);

                $this->assertTrue(
                    $ok,
                    "{$route}: <{$tag}> with no label — " . substr(preg_replace('/\s+/', ' ', $field), 0, 130)
                );
            }
        }
    }

    public function test_every_screen_has_a_main_landmark_and_a_navigation_landmark(): void
    {
        foreach (self::SCREENS as $route) {
            $html = $this->html($route);

            $this->assertStringContainsString('<main', $html, "{$route}: no <main> landmark");
            $this->assertStringContainsString('<nav', $html, "{$route}: no <nav> landmark");

            // A nav with no name is "navigation" to a screen reader, which is
            // useless when there is more than one.
            preg_match_all('/<nav\b[^>]*>/i', $html, $navs);

            foreach ($navs[0] as $nav) {
                $this->assertMatchesRegularExpression(
                    '/aria-label(ledby)?\s*=\s*"[^"]+"/i',
                    $nav,
                    "{$route}: unnamed <nav>"
                );
            }
        }
    }

    public function test_focus_outlines_are_never_removed_globally(): void
    {
        // `*:focus { outline: none }` is the single most common way a keyboard
        // user is locked out of a page.
        $css = file_get_contents(base_path('resources/css/portal.css'));

        $this->assertDoesNotMatchRegularExpression('/\*\s*:focus\s*\{[^}]*outline\s*:\s*none/i', $css);
        $this->assertDoesNotMatchRegularExpression('/^\s*:focus\s*\{[^}]*outline\s*:\s*none/im', $css);
    }

    public function test_motion_is_disabled_for_anyone_who_asks_for_less(): void
    {
        $css = file_get_contents(base_path('resources/css/portal.css'));

        $this->assertStringContainsString('prefers-reduced-motion', $css);
    }

    public function test_a_visible_focus_ring_is_stated_rather_than_left_to_the_browser(): void
    {
        // The default ring is a thin blue line, which disappears against a
        // tenant whose brand colour is blue.
        $css = file_get_contents(base_path('resources/css/portal.css'));

        $this->assertMatchesRegularExpression('/:focus-visible\s*\{[^}]*outline\s*:/i', $css);
    }

    public function test_validation_errors_are_announced(): void
    {
        // Asserted against the templates, not the rendered page: an error block
        // only renders when there IS an error, so a clean page proves nothing
        // either way. What must hold is that every place able to show one
        // announces it.
        $views = glob(resource_path('views/livewire/portal/*.blade.php'));

        foreach ($views as $view) {
            $source = file_get_contents($view);

            if (! str_contains($source, '@error')) {
                continue;
            }

            $name = basename($view);

            // Every message a validator can produce is inside a live region.
            preg_match_all('/@error\b.*?@enderror/s', $source, $blocks);

            foreach ($blocks[0] as $block) {
                if (! str_contains($block, '{{ $message }}')) {
                    continue;
                }

                $this->assertMatchesRegularExpression(
                    '/role="alert"/i',
                    $block,
                    "{$name}: a validation message that no screen reader will announce"
                );
            }
        }
    }

    public function test_the_layout_announces_flash_messages(): void
    {
        $layout = file_get_contents(resource_path('views/layouts/portal.blade.php'));

        $this->assertStringContainsString('role="status"', $layout, 'success messages are not announced');
        $this->assertStringContainsString('role="alert"', $layout, 'error messages are not announced');
    }

    public function test_numeric_inputs_read_left_to_right(): void
    {
        // An Arabic document renders a number right-to-left otherwise, and the
        // caret jumps while somebody is typing an amount or a phone number.
        foreach (['portal.requests.create', 'portal.documents'] as $route) {
            preg_match_all('/<input\b[^>]*type\s*=\s*"(date|tel|number)"[^>]*>/i', $this->html($route), $inputs);

            foreach ($inputs[0] as $input) {
                $this->assertMatchesRegularExpression(
                    '/\bdir\s*=\s*"ltr"/i',
                    $input,
                    "{$route}: numeric input without dir=ltr — " . substr($input, 0, 110)
                );
            }
        }
    }
}
