<?php

namespace Tests\Feature;

use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;

/**
 * Four defects in the browser code, checked by running it in a browser.
 *
 * Every one of these is behaviour rather than text, so none of them can be
 * checked by reading the file for strings. The precedent is in this repo already:
 * a test that asserted "needsRetryToHelp" appeared somewhere in chat.js stayed
 * green after the flag was deleted from the action it guarded. So the harnesses
 * under tests/js execute the shipped code - sliced out of the real file, or loaded
 * whole - in headless Chrome, and this asserts on what they report.
 *
 * Chrome specifically, not a DOM stub, because two of the four turn on what the
 * browser does rather than what our code says: which elements a selector matches
 * inside a settled group, and what the stylesheet computes for an element once its
 * ancestor is greyed. A stub answers whatever it was written to answer, and my
 * first attempt at one of these did exactly that - see the zoom case below.
 */
class FourThingsTheBrowserWasDoingTest extends TestCase
{
    /**
     * Run a harness and decode what it saw.
     *
     * A harness that cannot run is a failure, never a skip. Three guardrail tests
     * in this suite once vanished on a machine without node while phpunit still
     * reported "passed" - counted in the total, never executed. CI without node is
     * a CI problem, and this is how it gets noticed.
     *
     * @return array<string, mixed>
     */
    private function harness(string $script, array $arguments = []): array
    {
        $command = sprintf(
            'node %s %s 2>&1',
            escapeshellarg(base_path('tests/js/'.$script)),
            implode(' ', array_map(escapeshellarg(...), $arguments)),
        );

        $output = shell_exec($command);
        $decoded = json_decode((string) $output, true);

        if (! is_array($decoded) || isset($decoded['error'])) {
            $this->fail(sprintf(
                'The %s harness could not run, so nothing was checked. node and a puppeteer '
                .'browser must be installed (npm ci, then npx puppeteer browsers install chrome). '
                .'Output: %s',
                $script,
                trim((string) $output),
            ));
        }

        return $decoded;
    }

    /** @return array<string, mixed> */
    private function choiceBoxes(): array
    {
        static $result = null;

        return $result ??= $this->harness('choice-boxes.cjs', [
            public_path('assets/js/chat.js'),
            public_path('assets/mb/css/app.css'),
        ]);
    }

    // ---------------- 30: every question in a group stamped "Skipped"

    /**
     * Confirming a group does not mark its own questions skipped.
     *
     * settle(group) added is-settled to the group and nothing else, and
     * retireUnansweredChoices() matches `.mb-choice:not(.is-settled)` anywhere in
     * the transcript - so the questions inside a settled group still matched.
     * Confirming three answers stamped all three "Skipped" on the spot, directly
     * under a badge reading "Selections confirmed". The comment on the confirm
     * handler said settle() was what prevented this, which was the belief rather
     * than the behaviour.
     */
    public function test_confirming_a_group_does_not_stamp_its_own_questions(): void
    {
        $group = $this->choiceBoxes()['groupAfter'];

        $this->assertSame([false, false, false], $group['questionsSkipped'], 'answered questions were marked skipped');
        $this->assertSame(0, $group['skippedNotes'], 'a "Skipped" note was written under a confirmed group');
        $this->assertSame(1, $group['confirmedBadge'], 'the group did not report itself confirmed');
    }

    /** The questions are settled with the group, which is what makes them immune. */
    public function test_the_questions_are_settled_with_their_group(): void
    {
        $group = $this->choiceBoxes()['groupAfter'];

        $this->assertTrue($group['groupSettled']);
        $this->assertSame([true, true, true], $group['questionsSettled']);
    }

    /**
     * A group that arrives already settled is left alone too.
     *
     * The state can come from the server rather than from settle(): the transcript
     * is rendered with its choices, and a group can come back settled while its
     * questions carry no class of their own. So the sweep needs its own guard, not
     * only settle() marking children - which is what the mutation run showed, by
     * deleting that guard without turning anything red.
     */
    public function test_a_group_that_was_already_settled_is_not_swept(): void
    {
        $group = $this->choiceBoxes()['serverSettledGroup'];

        $this->assertSame([false, false], $group['skipped']);
        $this->assertSame(0, $group['notes']);
    }

    /**
     * A question genuinely left unanswered is still stamped.
     *
     * The point of the sweep. Fixing the group case by weakening the sweep would
     * lose the record of what was bypassed, which is the thing it exists to keep.
     */
    public function test_an_unanswered_question_is_still_marked_skipped(): void
    {
        $orphan = $this->choiceBoxes()['orphan'];

        $this->assertTrue($orphan['skipped']);
        $this->assertSame(1, $orphan['notes']);
        $this->assertTrue($orphan['disabled']);
    }

    // ---------------- 31: the magnifier after a creative is chosen

    /**
     * The magnifier stays visible once the choice is settled.
     *
     * Not a pointer-events problem, which is what I assumed and a probe disproved:
     * Chrome delivers a click to a span inside a disabled button, and a disabled
     * button still matches :hover. The first version of this test measured clicks
     * and reported the broken shape as working.
     *
     * What actually happened is inheritance. The magnifier sat inside the option
     * button, and `.is-settled .mb-choice-option` is drawn at 45% opacity with
     * grayscale(1) so unchosen cards read as past - over a thumbnail already greyed
     * by the same rule. Whatever it did when hovered was multiplied by that, at the
     * one moment someone wants to look properly at the creative they just picked.
     */
    public function test_the_magnifier_is_not_greyed_out_with_the_card(): void
    {
        $zoom = $this->choiceBoxes()['cardsAfterChoosing'];

        $this->assertTrue($zoom['rendered'], 'no magnifier was rendered at all');
        $this->assertTrue($zoom['settled'], 'the card under test was not settled');
        $this->assertSame(1.0, (float) $zoom['effectiveOpacity'], 'the magnifier is faded on a settled card');
        $this->assertFalse($zoom['greyed'], 'the magnifier is desaturated on a settled card');

        // The card beside it is faded, so this is an escape rather than the
        // stylesheet simply not fading anything.
        $this->assertLessThan(0.5, (float) $zoom['optionOpacity']);
    }

    /**
     * The old shape, measured, so the number in the docblock is not a claim.
     *
     * This asserts what the discarded arrangement does rather than what the code
     * does now. If the stylesheet stops fading settled cards, this fails and the
     * explanation above needs rewriting - which is the correct outcome.
     */
    public function test_the_old_nesting_really_did_fade_it(): void
    {
        $old = $this->choiceBoxes()['oldNesting'];

        $this->assertTrue($old['insideOption']);
        $this->assertLessThan(0.5, (float) $old['inheritedOpacity']);
        $this->assertSame('grayscale(1)', $old['inheritedFilter']);
    }

    /** And it is out of the button, which is what lets it escape the fade. */
    public function test_the_magnifier_is_not_inside_the_option_button(): void
    {
        $cards = $this->choiceBoxes()['cards'];

        $this->assertTrue($cards['rendered'], 'chat.js rendered no magnifier for an image option');
        $this->assertFalse($cards['insideButton'], 'the magnifier is inside the option button again');
        $this->assertFalse($cards['insideOption']);
        $this->assertTrue($cards['inCard'], 'the magnifier is not a child of the card wrapper');
        $this->assertSame(2, $cards['optionCount'], 'the options themselves did not render');
    }

    // ---------------- 32: an object-shaped options payload

    /**
     * Options arriving as an object do not kill the turn.
     *
     * choiceLayout() read choice.options raw and called .some() on it. PHP encodes
     * an array with non-sequential keys as a JSON object, so options arrived as
     * {"0":{...},"2":{...}} whenever anything filtered the list - and .some is not
     * a function on an object. The TypeError was thrown while drawing the question,
     * so the whole turn's render stopped: no options, no reply, nothing on screen
     * to say why. normalizeOptions() had existed for exactly this since the
     * list-level fix and this call site never used it.
     */
    #[DataProvider('layoutCases')]
    public function test_the_question_renders_whatever_shape_the_options_arrive_in(string $key, string $layout): void
    {
        $case = $this->choiceBoxes()[$key];

        $this->assertSame([], $case['errors'], 'the page threw while drawing the question');
        $this->assertTrue($case['rendered'], 'nothing was drawn, so the turn showed no options at all');
        $this->assertStringContainsString($layout, $case['layout']);
        $this->assertSame(2, $case['optionCount'], 'the options did not reach the screen');
    }

    /** @return list<array{0: string, 1: string}> */
    public static function layoutCases(): array
    {
        return [
            'an object payload' => ['objectOptions', 'mb-choice-chips'],
            'an object payload with images' => ['objectOptionsWithImage', 'mb-choice-cards'],
            'an ordinary array' => ['arrayOptions', 'mb-choice-chips'],
        ];
    }

    // ---------------- 33: the poller that never stopped

    /**
     * The poller gives up, and gives the composer back.
     *
     * The catch re-armed the timer unconditionally, so a status endpoint that was
     * permanently unhappy was asked again every two seconds for as long as the tab
     * stayed open. Only finish() clears busy and that path never reaches it, so the
     * composer stayed dead: the page looked like it was still working, and there
     * was no way to type, cancel, or find out why.
     *
     * Three cases, each reaching a different ending, with the limits turned down so
     * they are reachable in a test. The composer is checked by trying to send
     * something and watching for the request - the lock is the `busy` flag guarding
     * submit(), not a class, so asserting on is-locked would have passed whatever
     * happened.
     *
     * @param  'always-fails'|'never-finishes'|'recovers'  $mode
     */
    #[DataProvider('pollerModes')]
    public function test_the_poller_stops_and_releases_the_composer(string $mode, string $expected): void
    {
        $result = $this->harness('landing-page-poller.cjs', [
            public_path('assets/js/prompt-landing-pages-chat.js'),
            $mode,
        ]);

        $this->assertFalse($result['stillPolling'], 'the poller was still asking after it should have given up');
        $this->assertTrue($result['composerUsableAfterwards'], 'the composer was left dead for that tab');
        $this->assertStringContainsString($expected, implode(' | ', $result['bubbles']));
    }

    /** @return list<array{0: string, 1: string}> */
    public static function pollerModes(): array
    {
        return [
            'an endpoint that always fails' => ['always-fails', 'could not check on the page'],
            'a job that never finishes' => ['never-finishes', 'has been generating for over'],
            'a blip it recovers from' => ['recovers', 'Here it is.'],
        ];
    }

    /**
     * A transient failure is still retried rather than given up on at the first.
     *
     * Two 503s then a good answer: the point of a retry is to survive exactly this,
     * and a ceiling that fires too early would trade one bad behaviour for another.
     */
    public function test_a_blip_is_retried(): void
    {
        $result = $this->harness('landing-page-poller.cjs', [
            public_path('assets/js/prompt-landing-pages-chat.js'),
            'recovers',
        ]);

        $this->assertGreaterThanOrEqual(3, $result['statusCalls'], 'it gave up before the endpoint recovered');
        $this->assertStringContainsString('Here it is.', implode(' ', $result['bubbles']));
    }
}
