import { describe, test, expect } from 'vitest';
import { purifyHtml } from './HtmlBlock';
// Vite/Vitest `?raw` import -- ships the exact bytes of the file as a
// string, declared by node_modules/vite/client.d.ts. This is a checked-in
// copy of the reference acceptance fixture used for Task 24 (widening the
// Custom HTML block's sanitiser allow-list); keep it byte-identical to the
// external fixture used to drive this task so these tests cannot silently
// drift from the thing they are supposed to be testing against.
import fixtureHtml from './__fixtures__/html-block-test-body.html?raw';

/**
 * Task 24: the site owner tested a broad HTML fixture against the shipped
 * sanitiser config and found 38% of it silently deleted -- merged table
 * cells collapsing (colspan/rowspan/scope stripped), <dl>/<sub>/<details>/
 * inline <svg>/<video>/<audio> dropped wholesale, lang/dir/role stripped
 * (breaking RTL rendering), <ol start/reversed> flattened. The fix widens
 * ALLOWED_TAGS/ALLOWED_ATTR in HtmlBlock.tsx. These tests run the *actual*
 * reference fixture through the *actual* purifyHtml() and assert the
 * previously-broken constructs now survive with their meaningful
 * attributes intact, while re-confirming (with attack payloads spliced
 * into the newly-widened surface -- forms, media, inline svg) that the
 * four non-negotiable security properties still hold.
 */

describe('purifyHtml -- Task 24 fixture regression (formerly-dropped constructs survive)', () => {
  const out = purifyHtml(fixtureHtml);

  test('table merged cells keep colspan/rowspan/scope', () => {
    expect(out).toContain('<td colspan="2">');
    expect(out).toContain('<td rowspan="2">');
    expect(out).toContain('<th scope="col">');
    expect(out).toContain('<th scope="row">');
  });

  test('definition list keeps its dl/dt/dd structure (was flattened to "TermDef")', () => {
    expect(out).toMatch(/<dl>[\s\S]*<dt>Term one<\/dt>[\s\S]*<dd>Definition of the first term\.<\/dd>[\s\S]*<\/dl>/);
  });

  test('menu list survives with nested buttons', () => {
    expect(out).toMatch(/<menu>[\s\S]*<button type="button">Copy<\/button>[\s\S]*<\/menu>/);
  });

  test('sub/sup survive (was flattened to "H2O")', () => {
    expect(out).toContain('H<sub>2</sub>O');
    expect(out).toContain('x<sup>2</sup>');
  });

  test('details/summary survive with the open attribute (was flattened)', () => {
    expect(out).toContain('<summary>Collapsed disclosure</summary>');
    expect(out).toContain('<details open="">');
  });

  test('hgroup survives', () => {
    expect(out).toMatch(/<hgroup>[\s\S]*<h2>Grouped heading<\/h2>/);
  });

  test('inline svg survives with its shape children and role/aria-label (was deleted entirely)', () => {
    expect(out).toMatch(/<svg[^>]*role="img"[^>]*aria-label="Two shapes"[^>]*>/);
    expect(out).toMatch(/<rect[^>]*fill="none"[^>]*stroke="currentColor"[^>]*>/);
    expect(out).toMatch(/<circle[^>]*cx="135"[^>]*cy="45"[^>]*r="40"[^>]*>/);
    expect(out).toMatch(/<text[^>]*text-anchor="middle"[^>]*>svg<\/text>/);
  });

  test('picture/source with media+srcset survive', () => {
    expect(out).toContain('<source media="(min-width: 800px)" srcset="wide.png">');
    expect(out).toContain('<source media="(min-width: 400px)" srcset="medium.png">');
  });

  test('video/audio survive with source/track children (was deleted entirely)', () => {
    expect(out).toMatch(/<video[^>]*controls=""[^>]*poster="poster\.jpg"[^>]*>/);
    expect(out).toContain('<source src="clip.webm" type="video/webm">');
    expect(out).toContain('<track kind="captions" src="captions.vtt" srclang="en" label="English">');
    expect(out).toMatch(/<audio[^>]*controls=""[^>]*>/);
  });

  test('canvas survives with its fallback text', () => {
    expect(out).toContain('<canvas width="200" height="60">Canvas fallback text</canvas>');
  });

  test('mark/small/del/ins survive as distinct elements (was flattened to "msdi")', () => {
    expect(out).toContain('<mark>mark</mark>');
    expect(out).toContain('<small>small</small>');
    expect(out).toContain('<del>del</del>');
    expect(out).toContain('<ins>ins</ins>');
  });

  test('lang/dir preserved for RTL text (was stripped, breaking Arabic/Hebrew rendering)', () => {
    expect(out).toContain('lang="ar" dir="rtl"');
    expect(out).toContain('lang="he" dir="rtl"');
  });

  test('role attribute preserved alongside aria-* (role was stripped)', () => {
    expect(out).toMatch(/<nav aria-label="Primary">/);
    expect(out).toMatch(/role="img"/);
  });

  test('ol start/reversed preserved (was flattened to plain <ol>)', () => {
    expect(out).toContain('<ol start="5" reversed="">');
  });

  test('text semantics survive: abbr/cite/q/time/data/kbd/samp/var/dfn/address/bdi/bdo/ruby', () => {
    expect(out).toContain('<abbr title="HyperText Markup Language">HTML</abbr>');
    expect(out).toContain('<kbd>Ctrl</kbd>');
    expect(out).toContain('<samp>output text</samp>');
    expect(out).toContain('<var>variable</var>');
    expect(out).toContain('<dfn>definition term</dfn>');
    expect(out).toContain('<address>');
    expect(out).toContain('<bdi>');
    expect(out).toContain('<bdo dir="rtl">');
    expect(out).toContain('<ruby>');
    expect(out).toContain('<rt>kan</rt>');
    expect(out).toContain('<time datetime="2026-08-09">');
    expect(out).toContain('<data value="42">');
  });

  test('wbr survives (word-break opportunity)', () => {
    expect(out).toContain('super<wbr>cali<wbr>fragilistic');
  });

  test('hidden attribute survives', () => {
    expect(out).toContain('<p hidden="">');
  });

  test('forms survive end-to-end: fieldset/legend/label/select/optgroup/option/textarea/datalist/output/progress/meter', () => {
    expect(out).toContain('<form action="#" method="get">');
    expect(out).toContain('<fieldset>');
    expect(out).toContain('<legend>Text inputs</legend>');
    expect(out).toContain('<label for="f-text">Text</label>');
    expect(out).toContain('<input id="f-text" name="text" type="text" placeholder="Placeholder" value="Prefilled">');
    expect(out).toContain('<input id="f-email" type="email" required="">');
    expect(out).toContain('<input id="f-num" type="number" min="0" max="100" step="5" value="25">');
    expect(out).toContain('<input id="f-ro" type="text" value="read only" readonly="">');
    expect(out).toContain('<input id="f-dis" type="text" value="disabled" disabled="">');
    expect(out).toContain('<input type="checkbox" name="c" value="1" checked="">');
    expect(out).toContain('<select id="f-select" name="select">');
    expect(out).toContain('<optgroup label="Group one">');
    expect(out).toContain('<option value="1" selected="">One</option>');
    expect(out).toContain('<select id="f-multi" multiple="" size="4">');
    expect(out).toContain('<datalist id="suggestions">');
    expect(out).toContain('<textarea id="f-area" rows="4" cols="40">');
    expect(out).toContain('<output name="result" for="f-num f-range">');
    expect(out).toContain('<progress id="f-prog" value="0.6">');
    expect(out).toContain('<meter id="f-meter" min="0" max="100" low="30" high="80" optimum="90" value="72">');
    expect(out).toContain('<button type="submit">Submit</button>');
  });

  test('bug fix: <select size> and <meter low/high/optimum> survive (both tags were already allowed, only these four attrs were missing)', () => {
    expect(out).toContain('<select id="f-multi" multiple="" size="4">');
    expect(out).toContain('low="30" high="80" optimum="90"');
  });

  test('fixture byte survival crosses 90% (was 61.6% -- 9739/15815 -- before Task 24)', () => {
    expect(out.length).toBeGreaterThan(fixtureHtml.length * 0.9);
  });
});

describe('purifyHtml -- Task 24: things in the fixture that must still be dropped', () => {
  const out = purifyHtml(fixtureHtml);

  test('style tag never survives', () => {
    expect(out).not.toMatch(/<style[\s>]/i);
  });

  test('script tag never survives', () => {
    expect(out).not.toMatch(/<script[\s>]/i);
  });

  test('dialog/template stay excluded (not in the widened allow-list)', () => {
    expect(out).not.toContain('<dialog');
    expect(out).not.toContain('<template');
  });

  test('no on* handler survives anywhere in the widened output, including inside the dialog fallback content', () => {
    expect(out).not.toMatch(/\son[a-z]+\s*=/i);
    // The fixture's dialog/close buttons carry onclick specifically to
    // prove this; their text content should still come through once the
    // handler is stripped and (for dialog) the wrapping tag is dropped.
    expect(out).toContain('Open dialog');
  });
});

describe('purifyHtml -- Task 24: security properties on newly-allowed elements', () => {
  test('script inside a newly-allowed <form> still never survives', () => {
    const out = purifyHtml('<form><script>alert(1)</script></form>');
    expect(out).not.toContain('<script');
  });

  test('on* handlers never survive on newly-allowed form controls', () => {
    const out = purifyHtml('<input onfocus="alert(1)" value="x">');
    expect(out).not.toMatch(/onfocus/i);
    const out2 = purifyHtml('<select onchange="alert(1)"><option>x</option></select>');
    expect(out2).not.toMatch(/onchange/i);
  });

  test('javascript: blocked in <form action>', () => {
    const out = purifyHtml('<form action="javascript:alert(1)"><button type="submit">go</button></form>');
    expect(out).not.toContain('javascript:');
  });

  test('formaction is not in the allow-list at all -- dropped regardless of value', () => {
    const out = purifyHtml('<button formaction="javascript:alert(1)">go</button>');
    expect(out).not.toContain('formaction');
    expect(out).not.toContain('javascript:');
  });

  test('javascript: blocked on svg <a xlink:href> (xlink:href is not allow-listed at all)', () => {
    const out = purifyHtml('<svg><a xlink:href="javascript:alert(1)">click</a></svg>');
    expect(out).not.toContain('javascript:');
    expect(out).not.toContain('xlink:href');
  });

  test('javascript: blocked in newly-allowed media URL attributes (poster, source src)', () => {
    const out = purifyHtml('<video poster="javascript:alert(1)"><source src="javascript:alert(2)"></video>');
    expect(out).not.toContain('javascript:');
  });

  test('javascript: still blocked in plain href alongside the widened surface', () => {
    const out = purifyHtml('<a href="javascript:alert(1)"><svg><text>x</text></svg></a>');
    expect(out).not.toContain('javascript:');
  });

  test('iframe still gets the forced restrictive sandbox + referrerpolicy alongside the widened surface', () => {
    const out = purifyHtml('<form><input></form><iframe src="https://example.com/"></iframe>');
    expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
    const sandbox = out.match(/sandbox="([^"]*)"/)![1];
    expect(sandbox).not.toMatch(/allow-top-navigation/);
    expect(out).toContain('referrerpolicy="no-referrer"');
  });

  test('on* on an iframe is still stripped even though iframe now sits among many more allowed siblings', () => {
    const out = purifyHtml('<iframe src="https://example.com/" onload="alert(1)"></iframe>');
    expect(out).not.toMatch(/onload/i);
  });

  test('Task 25: style tag nested inside the newly-allowed inline svg now survives, scoped', () => {
    // Was "style tag stays blocked" pre-Task-25 -- <style> is now a
    // deliberate escape hatch (see HtmlBlock.tsx's ALLOWED_TAGS/Task 25
    // comment), including copies nested inside inline SVG:
    // querySelectorAll('style') in scopeStyleBlocks() doesn't care about
    // namespace/nesting depth, because CSS itself doesn't respect SVG
    // subtree boundaries -- an unscoped <style> inside <svg> would still
    // apply page-wide, so it needs the same scoping as a top-level one.
    const out = purifyHtml('<svg><style>svg{color:red}</style><rect width="1" height="1"></rect></svg>');
    expect(out).toMatch(/<style/i);
    expect(out).not.toContain('<style>svg{color:red}</style>'); // rewritten, not verbatim
    expect(out).toMatch(/\.whp-html-\w+ svg\{color:red\}/);
    expect(out).toContain('<rect');
  });

  test('contenteditable does not smuggle an event handler in alongside it', () => {
    const out = purifyHtml('<div contenteditable="true" onblur="alert(1)">x</div>');
    expect(out).not.toMatch(/onblur/i);
    expect(out).toContain('contenteditable="true"');
  });

  test('dialog stays excluded even with an attack payload; its inert children still render', () => {
    const out = purifyHtml('<dialog onclick="alert(1)"><p>hi</p></dialog>');
    expect(out).not.toContain('<dialog');
    expect(out).not.toMatch(/onclick/i);
    expect(out).toContain('<p>hi</p>');
  });

  test('javascript: blocked via data: smuggling on newly-allowed poster/cite/action attributes', () => {
    // data: is only allow-listed for data:image/*;base64, -- confirm the
    // regex is not accidentally satisfied by a text/html or bare data:
    // payload on any of the newly URI-checked attributes.
    const out = purifyHtml(
      '<video poster="data:text/html,<script>alert(1)</script>"></video>' +
        '<blockquote cite="data:text/html,x">q</blockquote>' +
        '<form action="data:text/html,x"></form>',
    );
    expect(out).not.toContain('data:text/html');
  });

  test('review fix: data:image/*;base64, URIs now actually survive on poster/cite/href (dead-code regex bug)', () => {
    // ALLOWED_URI_REGEXP used to put the data:image arm inside the group
    // that gets a trailing `:` appended to every alternative, requiring a
    // second colon after the one already in "base64," -- which no real
    // data URI has, so the clause could never match anything. Confirm the
    // fixed regex actually allows a real base64 image data URI through on
    // ordinary URI-checked attributes (not just the DATA_URI_TAGS-covered
    // src ones tested below).
    const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
    const out = purifyHtml(
      `<video poster="data:image/png;base64,${b64}"></video>` +
        `<blockquote cite="data:image/png;base64,${b64}">q</blockquote>` +
        `<a href="data:image/png;base64,${b64}">img</a>`,
    );
    expect(out).toContain(`poster="data:image/png;base64,${b64}"`);
    expect(out).toContain(`cite="data:image/png;base64,${b64}"`);
    expect(out).toContain(`href="data:image/png;base64,${b64}"`);
  });

  test('documented reality: data: on img/video/audio/source src is mimetype-blind (DOMPurify DATA_URI_TAGS bypasses ALLOWED_URI_REGEXP)', () => {
    // This is NOT gated by ALLOWED_URI_REGEXP at all -- DOMPurify has its
    // own internal DATA_URI_TAGS allow-list (img, video, audio, source,
    // image, track) that accepts ANY data: URI on the `src` attribute of
    // those tags regardless of declared mimetype, before our regex is ever
    // consulted. Acceptable because none of those tags execute their src
    // as a document/script context in mainstream browsers -- the sink
    // doesn't execute. Pinned here so a future DOMPurify version change to
    // DATA_URI_TAGS shows up as a failing test, not a surprise in
    // production. <iframe> -- the one tag where this WOULD be dangerous --
    // is correctly not in DOMPurify's DATA_URI_TAGS list, so its src still
    // goes through the normal ALLOWED_URI_REGEXP check and gets stripped.
    const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
    const imgOut = purifyHtml(`<img src="data:text/html;base64,${b64}">`);
    expect(imgOut).toContain(`src="data:text/html;base64,${b64}"`);

    const iframeOut = purifyHtml(`<iframe src="data:text/html;base64,${b64}"></iframe>`);
    expect(iframeOut).not.toContain('data:');
  });
});

describe('purifyHtml -- bug fix: size/low/high/optimum were stripped despite select/meter being allowed tags', () => {
  test('<select size> survives with its value intact (multi-select row count)', () => {
    const out = purifyHtml('<select size="4"><option>a</option></select>');
    expect(out).toContain('size="4"');
  });

  test('<input size> survives with its value intact', () => {
    const out = purifyHtml('<input type="text" size="10">');
    expect(out).toContain('size="10"');
  });

  test('<meter low/high/optimum> survive with their values intact (threshold-based gauge colouring)', () => {
    const out = purifyHtml('<meter low="1" high="9" optimum="5" value="4" min="0" max="10"></meter>');
    expect(out).toContain('low="1"');
    expect(out).toContain('high="9"');
    expect(out).toContain('optimum="5"');
  });
});
