<?php

namespace Tests\Feature;

use App\Support\HtmlDocument;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;

/**
 * Parsing a landing page and saving it again gives back the same page.
 *
 * Five services do this - the public prompt-landing-page document, the three
 * catalog documents and the pixel injector - and each wrote the same eleven lines
 * of DOMDocument round trip. All five corrupted the page they published, in three
 * ways, none of which announced itself.
 *
 * Script bodies lose everything after a `</`. `var t = "</div>";` is saved as
 * `var t = "";` - emptied, not truncated, with the rest of the script intact
 * below it, so the page loads and the script runs wrongly. Any template string or
 * regex over a closing tag does it.
 *
 * SVG is lowercased, and SVG is case sensitive. `<linearGradient>` becomes
 * `<lineargradient>`, which is not an element, so `fill="url(#g)"` resolves to
 * nothing and the shape renders transparent. `viewBox` becomes `viewbox` and is
 * ignored, so the drawing is sized wrong.
 *
 * And libxml reported five errors on the gradient case and one on the script
 * case. Both were cleared unread, `loadHTML` returned true in every case tried,
 * so the `if (! $loaded)` guard never fired once.
 */
class APublishedPageSurvivesTheRoundTripTest extends TestCase
{
    private function roundTrip(string $html): string
    {
        $document = HtmlDocument::from($html);

        $this->assertNotNull($document, 'the page could not be parsed at all');

        return $document->html();
    }

    // --------------------------------------------------------------- scripts

    /** @return list<array{0: string, 1: string}> */
    public static function scriptBodies(): array
    {
        return [
            'a closing tag in a string' => ['var t = "</div>";', '</div>'],
            'a regex over a closing tag' => ['var re = /<\/p>/g;', '<\/p>'],
            'an innerHTML assignment' => ['el.innerHTML = "<span>hi</span>";', '<span>hi</span>'],
            'a template literal' => ['const row = `</tr><tr>`;', '</tr><tr>'],
            'document.write' => ['document.write("</body>");', '</body>'],
            'a comparison' => ['if (a < b && c > d) { go(); }', 'a < b && c > d'],
            'an ampersand' => ['if (a && b) { go(); }', 'a && b'],
        ];
    }

    #[DataProvider('scriptBodies')]
    public function test_a_script_body_comes_back_intact(string $body, string $fragment): void
    {
        $result = $this->roundTrip('<html><body><p>Hi</p><script>'.$body.'</script></body></html>');

        $this->assertStringContainsString($fragment, $result, 'the script body was altered');
        $this->assertStringContainsString($body, $result);
    }

    /** A style body too, which has the same problem with its content strings. */
    public function test_a_style_body_comes_back_intact(): void
    {
        $css = '.a::after{content:"</b>"}';

        $this->assertStringContainsString($css, $this->roundTrip('<html><body><style>'.$css.'</style></body></html>'));
    }

    /** Two scripts are not confused with each other. */
    public function test_several_scripts_keep_their_own_bodies(): void
    {
        $result = $this->roundTrip(
            '<html><body><script>var a = "</i>";</script><p>x</p><script>var b = "</u>";</script></body></html>'
        );

        $this->assertStringContainsString('var a = "</i>";', $result);
        $this->assertStringContainsString('var b = "</u>";', $result);
    }

    /**
     * A script added while the document is open is protected too.
     *
     * This is how all five callers work: they parse, append a pixel or a capture
     * script, and save. A script added through the DOM has never been through the
     * parser, so it has to be held out at save time instead.
     */
    public function test_a_script_added_through_the_dom_is_protected(): void
    {
        $document = HtmlDocument::from('<html><body><p>Hi</p></body></html>');
        $dom = $document->dom();

        $script = $dom->createElement('script');
        $script->textContent = 'window.track = function(){ el.innerHTML = "</div>"; };';
        $dom->getElementsByTagName('body')->item(0)->appendChild($script);

        $this->assertStringContainsString('el.innerHTML = "</div>";', $document->html());
    }

    // ------------------------------------------------------------------- SVG

    /** @return list<array{0: string}> */
    public static function svgNames(): array
    {
        return [
            'viewBox' => ['<svg viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/></svg>'],
            'linearGradient' => ['<svg><defs><linearGradient id="g"><stop offset="0"/></linearGradient></defs></svg>'],
            'radialGradient' => ['<svg><defs><radialGradient id="g"><stop offset="0"/></radialGradient></defs></svg>'],
            'gradientTransform' => ['<svg><defs><linearGradient id="g" gradientTransform="rotate(45)"/></defs></svg>'],
            'preserveAspectRatio' => ['<svg preserveAspectRatio="xMidYMid slice"><rect/></svg>'],
            'clipPath' => ['<svg><clipPath id="c"><rect/></clipPath></svg>'],
            'feGaussianBlur' => ['<svg><filter id="f"><feGaussianBlur stdDeviation="3"/></filter></svg>'],
            'markerWidth' => ['<svg><marker markerWidth="6" markerHeight="6" refX="3"><path/></marker></svg>'],
            'foreignObject' => ['<svg><foreignObject width="10" height="10"><span>x</span></foreignObject></svg>'],
        ];
    }

    /** SVG's own spelling survives, because SVG is case sensitive and HTML is not. */
    #[DataProvider('svgNames')]
    public function test_svg_keeps_its_capitals(string $svg): void
    {
        $result = $this->roundTrip('<html><body>'.$svg.'</body></html>');

        // Every camelCase name in the input is still spelled that way.
        preg_match_all('/\b([a-z]+[A-Z][A-Za-z]*)\b/', $svg, $names);

        foreach (array_unique($names[1]) as $name) {
            $this->assertStringContainsString($name, $result, "{$name} was lowercased");
            $this->assertStringNotContainsString(strtolower($name), $result, strtolower($name).' survived');
        }
    }

    /**
     * The gradient case end to end, because this is what went transparent.
     *
     * A rect filled from url(#g) renders as nothing at all when the gradient's
     * element name has been lowercased into something SVG does not recognise.
     */
    public function test_a_gradient_fill_still_resolves(): void
    {
        $result = $this->roundTrip(
            '<html><body><svg viewBox="0 0 10 10"><defs>'
            .'<linearGradient id="g" gradientTransform="rotate(45)"><stop offset="0" stop-color="#f00"/></linearGradient>'
            .'</defs><rect fill="url(#g)" width="10" height="10"/></svg></body></html>'
        );

        $this->assertStringContainsString('<linearGradient id="g"', $result);
        $this->assertStringContainsString('gradientTransform="rotate(45)"', $result);
        $this->assertStringContainsString('viewBox="0 0 10 10"', $result);
        $this->assertStringContainsString('fill="url(#g)"', $result);
    }

    /** An ordinary word that looks like an SVG name in prose is left alone. */
    public function test_prose_is_not_recapitalised(): void
    {
        $result = $this->roundTrip('<html><body><p>The viewbox and clippath of the thing.</p></body></html>');

        $this->assertStringContainsString('The viewbox and clippath of the thing.', $result);
    }

    // ------------------------------------------------------------- the basics

    /** An ordinary page is returned unchanged. */
    public function test_an_ordinary_page_is_unchanged(): void
    {
        $html = '<html><head><title>Offer</title></head><body><h1>Hi</h1>'
            .'<a href="/signup?a=1&amp;b=2">Go</a></body></html>';

        $this->assertSame($html, trim($this->roundTrip($html)));
    }

    /**
     * The libxml setting is put back, so it does not leak into the next thing.
     *
     * Asserted from a known starting point in both directions. Reading the
     * setting, calling, and comparing the two reads is not enough: a leak of
     * `true` passes that if the setting happened to be `true` already, which is
     * exactly what an earlier round trip would have left behind.
     */
    #[DataProvider('libxmlSettings')]
    public function test_the_libxml_error_setting_is_restored(bool $starting): void
    {
        $original = libxml_use_internal_errors($starting);

        try {
            $this->roundTrip('<html><body><svg viewBox="0 0 1 1"><rect/></svg></body></html>');

            // Read without disturbing it: set to the value we expect, and check
            // that is what was already there.
            $this->assertSame(
                $starting,
                libxml_use_internal_errors($starting),
                'the internal-error setting leaked out of the round trip',
            );
        } finally {
            libxml_use_internal_errors($original);
        }
    }

    /** @return list<array{0: bool}> */
    public static function libxmlSettings(): array
    {
        return ['off before' => [false], 'on before' => [true]];
    }

    /** And nothing is left in the libxml error buffer for the next caller. */
    public function test_no_errors_are_left_behind(): void
    {
        $this->roundTrip('<html><body><my-widget>x</my-widget><p>y</p></body></html>');

        $this->assertSame([], libxml_get_errors());
    }
}
