<?php

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

/**
 * Note body sanitizer (app/Helpers/utils_helper.php).
 *
 * The Notes page runs a rich text editor, so a note body is stored and rendered
 * as HTML rather than escaped. notes_safe_html() is therefore the only thing
 * standing between a note author and stored XSS against every other mngt user,
 * and it runs on read as well as write — notes created by legacy AdCenter never
 * passed through the current create() path.
 *
 * Runs on the lightweight tests/bootstrap.php (no CI4 boot): the helper is
 * required directly and depends on nothing but the dom extension.
 */
final class NotesSanitizerTest extends TestCase
{
    public static function setUpBeforeClass(): void
    {
        require_once dirname(__DIR__, 2) . '/app/Helpers/utils_helper.php';
    }

    // ── What must survive ────────────────────────────────────────────────────

    /** @dataProvider preservedProvider */
    public function testKeepsAllowedFormatting(string $input, string $expected): void
    {
        $this->assertSame($expected, notes_safe_html($input));
    }

    public static function preservedProvider(): array
    {
        return [
            'paragraph'      => ['<p>hello</p>', '<p>hello</p>'],
            'nested inline'  => [
                '<p><strong>bold <em>both</em></strong></p>',
                '<p><strong>bold <em>both</em></strong></p>',
            ],
            'list'           => ['<ul><li>a</li><li>b</li></ul>', '<ul><li>a</li><li>b</li></ul>'],
            'heading'        => ['<h2>Title</h2>', '<h2>Title</h2>'],
            'blockquote'     => ['<blockquote>quoted</blockquote>', '<blockquote>quoted</blockquote>'],
            'legacy table'   => [
                '<table><tbody><tr><td>c1</td></tr></tbody></table>',
                '<table><tbody><tr><td>c1</td></tr></tbody></table>',
            ],
            'plain text'     => ['just words', 'just words'],
            // Legacy notes contain smart quotes and em dashes; libxml defaults to
            // ISO-8859-1 and would mangle them without an explicit UTF-8 meta.
            'utf8 preserved' => [
                "<p>caf\u{00e9} \u{2014} \u{201c}quoted\u{201d} \u{65e5}\u{672c}\u{8a9e}</p>",
                "<p>caf\u{00e9} \u{2014} \u{201c}quoted\u{201d} \u{65e5}\u{672c}\u{8a9e}</p>",
            ],
        ];
    }

    public function testKeepsSafeLinkAndHardensIt(): void
    {
        $out = notes_safe_html('<a href="https://admedia-jira.atlassian.net/browse/AO-8308">AO-8308</a>');

        $this->assertStringContainsString('href="https://admedia-jira.atlassian.net/browse/AO-8308"', $out);
        $this->assertStringContainsString('target="_blank"', $out);
        $this->assertStringContainsString('rel="noopener noreferrer"', $out);
    }

    public function testKeepsMailtoLink(): void
    {
        $this->assertStringContainsString('href="mailto:a@b.com"', notes_safe_html('<a href="mailto:a@b.com">mail</a>'));
    }

    /** An unknown wrapper loses the tag but must not lose the words. */
    public function testUnwrapsDisallowedTagButKeepsText(): void
    {
        $this->assertSame('keep these words', notes_safe_html('<div><span>keep these words</span></div>'));
    }

    // ── What must not survive ────────────────────────────────────────────────

    /** @dataProvider strippedProvider */
    public function testStripsDangerousMarkup(string $input, array $mustNotContain): void
    {
        $out = notes_safe_html($input);

        foreach ($mustNotContain as $needle) {
            $this->assertStringNotContainsString($needle, $out, sprintf('%s survived sanitizing', $needle));
        }
    }

    public static function strippedProvider(): array
    {
        return [
            // Script text is not content — unwrapping would paste alert(1) into the
            // page as visible text and re-arm it on any later re-parse.
            'script tag'      => ['<p>hi</p><script>alert(1)</script>', ['<script', 'alert(1)']],
            'style tag'       => ['<style>body{display:none}</style>ok', ['<style', 'display:none']],
            'event handler'   => ['<img src=x onerror=alert(1)>bad', ['onerror', '<img']],
            'inline style'    => ['<p style="position:fixed" class="x">t</p>', ['style=', 'class=']],
            'onclick on p'    => ['<p onclick="alert(1)">t</p>', ['onclick']],
            'javascript href' => ['<a href="javascript:alert(1)">c</a>', ['javascript']],
            // "java\nscript:" parses as a path, not a scheme, but browsers honour it.
            'obfuscated href' => ["<a href=\"java\nscript:alert(1)\">c</a>", ['javascript', "java\nscript"]],
            'data href'       => ['<a href="data:text/html,<b>x</b>">c</a>', ['data:']],
            'protocol rel'    => ['<a href="//evil.com/x">c</a>', ['evil.com']],
            'iframe'          => ['<iframe src="//evil.com"></iframe>ok', ['<iframe', 'evil.com']],
            'svg script'      => ['<svg><script>alert(1)</script></svg>ok', ['<svg', 'alert(1)']],
            'conditional cmt' => ['<p>a</p><!--[if IE]><script>alert(1)</script><![endif]-->', ['alert(1)', '<!--']],
            'form controls'   => ['<form action="/x"><input name="a"></form>text', ['<form', '<input']],
        ];
    }

    public function testStripsHrefButKeepsLinkText(): void
    {
        $this->assertSame('<a>click</a>', notes_safe_html('<a href="javascript:alert(1)">click</a>'));
    }

    // ── Blank detection ──────────────────────────────────────────────────────

    /** @dataProvider blankProvider */
    public function testBlankDetection(string $input, bool $expected): void
    {
        $this->assertSame($expected, notes_html_is_blank(notes_safe_html($input)));
    }

    public static function blankProvider(): array
    {
        return [
            // An emptied editor still posts markup, so '' is never what arrives.
            'empty string'   => ['', true],
            'nbsp only'      => ['<p>&nbsp;</p>', true],
            'br only'        => ['<p><br></p>', true],
            'empty para'     => ['<p></p>', true],
            'whitespace'     => ["<p>   \n\t </p>", true],
            'markup only'    => ['<script>alert(1)</script>', true],
            'real text'      => ['<p>a</p>', false],
            'text in markup' => ['<div><span>x</span></div>', false],
        ];
    }

    public function testEmptyInputReturnsEmptyString(): void
    {
        $this->assertSame('', notes_safe_html(''));
        $this->assertSame('', notes_safe_html('   '));
    }

    /** Sanitizing twice must not change the result — index() re-sanitizes on read. */
    public function testIsIdempotent(): void
    {
        $inputs = [
            '<p>hi <strong>there</strong></p>',
            '<a href="https://example.com/x">link</a>',
            '<div><span>unwrapped</span></div>',
            "<p>caf\u{00e9} \u{2014} \u{201c}q\u{201d}</p>",
            '<table><tbody><tr><td>c</td></tr></tbody></table>',
        ];

        foreach ($inputs as $input) {
            $once = notes_safe_html($input);
            $this->assertSame($once, notes_safe_html($once), sprintf('not idempotent for %s', $input));
        }
    }
}
