import { describe, test, expect } from 'vitest';
import { purifyHtml } from './HtmlBlock';
import { stableHash } from '../../utils/escape';
import fixtureHtml from './__fixtures__/html-block-test-body.html?raw';
// Ground truth "before" output: purifyHtml(fixtureHtml) computed with the
// EXACT HtmlBlock.tsx code as it stood at commit 6a9b227 (the commit
// immediately before Task 25 -- `git show
// 6a9b227:craft/src/components/basic/HtmlBlock.tsx`), run against the real
// dompurify+jsdom, not guessed at or re-derived from reading the code. See
// the "byte-diff against 6a9b227" describe block below -- this is the
// literal regression check the Task 25 review asked for, after the first
// round of `.not.toContain(...)`-style tests passed while FORCE_BODY was
// silently changing output for a comment-led, style-free fixture.
import preTask25FixtureOutput from './__fixtures__/html-block-test-body.pre-task25-output.html?raw';

describe('purifyHtml', () => {
  test('strips script tags', () => {
    expect(purifyHtml('<p>ok</p><script>alert(1)</script>')).not.toContain('<script');
  });
  test('strips on-event handlers', () => {
    const out = purifyHtml('<a onclick="bad()" href="/x">x</a>');
    expect(out).not.toContain('onclick');
    expect(out).toContain('href="/x"');
  });
  test('blocks javascript: URLs', () => {
    expect(purifyHtml('<a href="javascript:void(0)">x</a>')).not.toContain('javascript:');
  });
  test('allows YouTube iframe', () => {
    const out = purifyHtml('<iframe src="https://www.youtube.com/embed/abc" allowfullscreen></iframe>');
    expect(out).toContain('youtube.com/embed/abc');
  });
  test('allows form/input (Task 24: forms are a deliberate escape-hatch addition) but still strips on*/script inside them', () => {
    const out = purifyHtml('<form><input name="x" onfocus="bad()"><script>alert(1)</script></form>');
    expect(out).toContain('<form');
    expect(out).toContain('<input name="x">');
    expect(out).not.toContain('onfocus');
    expect(out).not.toContain('<script');
  });
});

describe('purifyHtml markup path (C1 review finding)', () => {
  test('a style attribute survives sanitization (colour picker output must not be silently dropped)', () => {
    const out = purifyHtml('<p style="color: #ff0000">red text</p>');
    expect(out).toBe('<p style="color: #ff0000">red text</p>');
  });

  test('an id attribute survives sanitization (anchor targets)', () => {
    const out = purifyHtml('<a href="#section" id="section">link</a>');
    expect(out).toContain('id="section"');
  });

  test('a pasted table survives sanitization', () => {
    const input = '<table><thead><tr><th>Head</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>';
    expect(purifyHtml(input)).toBe(input);
  });

  test('script tags still do not survive alongside a style attribute', () => {
    const out = purifyHtml('<p style="color:#ff0000">ok</p><script>alert(1)</script>');
    expect(out).not.toContain('<script');
    expect(out).toContain('style="color:#ff0000"');
  });

  test('on* handlers still do not survive on an element that also carries style', () => {
    const out = purifyHtml('<p style="color:#ff0000" onclick="bad()">x</p>');
    expect(out).not.toContain('onclick');
    expect(out).toContain('style="color:#ff0000"');
  });

  test('javascript: URLs still do not survive on an element that also carries style', () => {
    const out = purifyHtml('<a style="color:#ff0000" href="javascript:void(0)">x</a>');
    expect(out).not.toContain('javascript:');
    expect(out).toContain('style="color:#ff0000"');
  });
});

describe('purifyHtml iframe sandboxing (M-6)', () => {
  test('forces a restrictive sandbox attribute onto every iframe', () => {
    const out = purifyHtml('<iframe src="https://example.com/"></iframe>');
    expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
  });

  test('sandbox value omits allow-top-navigation (no top-level nav escape)', () => {
    const out = purifyHtml('<iframe src="https://example.com/"></iframe>');
    const sandbox = out.match(/sandbox="([^"]*)"/)![1];
    expect(sandbox).not.toMatch(/allow-top-navigation/);
  });

  test('legitimate embeds (YouTube) still work and get sandboxed too', () => {
    const out = purifyHtml('<iframe src="https://www.youtube.com/embed/abc" allowfullscreen></iframe>');
    expect(out).toContain('youtube.com/embed/abc');
    expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
  });

  test('adds referrerpolicy=no-referrer to iframes', () => {
    const out = purifyHtml('<iframe src="https://example.com/"></iframe>');
    expect(out).toContain('referrerpolicy="no-referrer"');
  });

  test('script/on* attributes are still stripped alongside the sandboxed iframe', () => {
    const out = purifyHtml('<iframe src="https://example.com/" onload="alert(1)"></iframe><script>alert(2)</script>');
    expect(out).not.toContain('onload');
    expect(out).not.toContain('<script');
  });

  test('repeated calls do not leak/accumulate the hook (no duplicate sandbox attr, no cross-call state)', () => {
    purifyHtml('<iframe src="https://a.example/"></iframe>');
    purifyHtml('<iframe src="https://b.example/"></iframe>');
    const out = purifyHtml('<iframe src="https://c.example/"></iframe>');
    const sandboxMatches = out.match(/sandbox="/g) || [];
    expect(sandboxMatches.length).toBe(1);
  });

  test('a non-iframe element sanitized alongside an iframe is not touched by the hook', () => {
    const out = purifyHtml('<p>hi</p><iframe src="https://example.com/"></iframe>');
    expect(out).toContain('<p>hi</p>');
  });
});

describe('purifyHtml -- Task 25: block-scoped <style> support', () => {
  test('a block with no <style> at all is untouched: no wrapper div added', () => {
    const out = purifyHtml('<p>hello</p>');
    expect(out).toBe('<p>hello</p>');
    expect(out).not.toContain('<div');
  });

  test('blocks WITHOUT <style> are byte-identical to pre-Task-25 output (no wrapper regression)', () => {
    // Same representative inputs the Task 24 suite already pins to an exact
    // string -- re-asserted here under the Task 25 name so a future change
    // that starts wrapping every block (not just style-bearing ones) fails
    // loudly and obviously, not just as a Task 24 side-effect.
    expect(purifyHtml('<p style="color: #ff0000">red text</p>')).toBe('<p style="color: #ff0000">red text</p>');
    const table = '<table><thead><tr><th>Head</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>';
    expect(purifyHtml(table)).toBe(table);
    expect(purifyHtml('<a href="/x">x</a>')).toBe('<a href="/x">x</a>');
  });

  test('the full Task 24 fixture (no <style> in it) produces no wrapper and is unaffected', () => {
    // The fixture is the broadest real-world stand-in this repo has for
    // "a customer's actual pasted block". It contains no <style>, so this
    // is the closest thing to a real before/after diff over a large,
    // realistic input: the only lever Task 25 pulled (allowing <style> +
    // FORCE_BODY) must produce PRECISELY the same output as before for
    // content that never touches that lever.
    const out = purifyHtml(fixtureHtml);
    expect(out).not.toContain('<div class="whp-html-');
    expect(out).not.toMatch(/<style[\s>]/i); // still no bare <style> in this fixture
  });

  test('an empty <style></style> (no CSS content) does not trigger a wrapper', () => {
    const out = purifyHtml('<p>hi</p><style></style>');
    expect(out).not.toContain('<div class="whp-html-');
  });

  test('a whitespace-only <style> does not trigger a wrapper', () => {
    const out = purifyHtml('<p>hi</p><style>   \n  </style>');
    expect(out).not.toContain('<div class="whp-html-');
  });

  test('a block WITH real <style> content gets wrapped in a scope-class div', () => {
    const out = purifyHtml('<style>h1 { color: red; }</style><h1>Hi</h1>');
    expect(out).toMatch(/^<div class="whp-html-[0-9a-z]+">/);
    expect(out).toContain('<h1>Hi</h1>');
  });

  test('the style content is rewritten to only match inside the wrapper (the actual leak-prevention property)', () => {
    const out = purifyHtml('<style>h1 { color: red; }</style><h1>Hi</h1>');
    const scopeClass = out.match(/class="(whp-html-[0-9a-z]+)"/)![1];
    expect(out).toContain(`.${scopeClass} h1 { color: red; }`);
    // The bare, unscoped rule must not appear anywhere in the output --
    // that's exactly the leak this feature exists to close.
    expect(out).not.toContain('<style>h1 { color: red; }</style>');
  });

  test('scope class is deterministic: the SAME code produces the SAME class across repeated calls', () => {
    const code = '<style>p { color: blue; }</style><p>x</p>';
    const out1 = purifyHtml(code);
    const out2 = purifyHtml(code);
    expect(out1).toBe(out2);
    const class1 = out1.match(/class="(whp-html-[0-9a-z]+)"/)![1];
    const class2 = out2.match(/class="(whp-html-[0-9a-z]+)"/)![1];
    expect(class1).toBe(class2);
  });

  test('pinned scope class for a known input -- guards against silent hash-function drift', () => {
    // If this ever needs to change, it means the hash function itself
    // changed -- which would silently churn every stored site's HTML on
    // next save and desync already-published pages from a fresh Preview.
    // That should be a loud, deliberate decision, not a side-effect of an
    // unrelated refactor -- hence pinning the literal output here.
    const code = '<style>h1{color:red}</style>';
    expect(stableHash(code)).toBe('5fwbyn');
    const out = purifyHtml(code);
    expect(out).toContain('class="whp-html-5fwbyn"');
  });

  test('scope class is a pure function of `code` -- does not depend on Craft node id or call order', () => {
    // purifyHtml's signature only ever takes the code string -- there is no
    // node id parameter it could even reach for. This test documents that
    // invariant so a future refactor threading a node id through here (as
    // html-export.ts's renderNode already does for OTHER components, see
    // its `scopeId` comment) doesn't silently get wired into this path too.
    const codeA = '<style>h1 { color: red; }</style><h1>same content</h1>';
    const codeB = '<style>h1 { color: red; }</style><h1>same content</h1>';
    expect(codeA).toBe(codeB); // sanity: truly identical strings
    const outA = purifyHtml(codeA);
    const outB = purifyHtml(codeB);
    expect(outA).toBe(outB);
  });

  test('FORCE_BODY regression: a block whose ENTIRE code is a leading <style> (nothing before it) still survives', () => {
    // Without FORCE_BODY, DOMPurify parses `code` via DOMParser as a mini
    // HTML document and serializes only <body>. Per the HTML5 parsing
    // algorithm, a <style> tag with nothing before it is implicitly placed
    // in the parser's <head>, which DOMPurify's body-only serialization
    // never looks at -- the whole block would silently vanish. Confirmed
    // empirically against dompurify+jsdom directly before this fix existed.
    const out = purifyHtml('<style>h1{color:red}</style>');
    expect(out).toContain('<style>');
    expect(out).toContain('color:red');
  });

  test('FORCE_BODY regression: leading <style> immediately followed by markup, both survive', () => {
    const out = purifyHtml('<style>h1{color:red}</style><h1>Hi</h1>');
    expect(out).toContain('<h1>Hi</h1>');
    expect(out).toMatch(/<style>[\s\S]*color:\s*red/);
  });

  test(':root / html / body inside a block map to the block wrapper itself (end-to-end through purifyHtml)', () => {
    const out = purifyHtml('<style>:root { --brand: red; } body { margin: 0; }</style><p>x</p>');
    const scopeClass = out.match(/class="(whp-html-[0-9a-z]+)"/)![1];
    expect(out).toContain(`.${scopeClass} { --brand: red; }`);
    expect(out).toContain(`.${scopeClass} { margin: 0; }`);
  });

  test('@import is stripped end-to-end (network-fetch/exfiltration channel)', () => {
    const out = purifyHtml('<style>@import url("https://evil.example/x.css"); h1{color:red}</style><h1>x</h1>');
    expect(out).not.toContain('@import');
    expect(out).not.toContain('evil.example');
    expect(out).toContain('color:red');
  });

  test('@keyframes body is not scoped (animation would otherwise break) -- end-to-end through purifyHtml', () => {
    const out = purifyHtml(
      '<style>@keyframes spin { from { opacity: 0; } to { opacity: 1; } }</style><h1>x</h1>',
    );
    expect(out).toContain('@keyframes spin');
    expect(out).toMatch(/@keyframes spin\s*\{\s*from\s*\{\s*opacity:\s*0;?\s*\}\s*to\s*\{\s*opacity:\s*1;?\s*\}\s*\}/);
  });

  test('multiple <style> blocks in one Custom HTML block are each scoped under the SAME class', () => {
    const out = purifyHtml('<style>h1{color:red}</style><h1>A</h1><style>p{color:blue}</style><p>B</p>');
    const classes = [...out.matchAll(/class="(whp-html-[0-9a-z]+)"/g)].map((m) => m[1]);
    expect(classes.length).toBeGreaterThanOrEqual(1);
    expect(new Set(classes).size).toBe(1); // same block -> same scope class everywhere
  });
});

describe('purifyHtml -- Task 25: security properties of the newly-allowed <style>', () => {
  test('</style> inside a CSS comment cannot break out into executable markup', () => {
    const out = purifyHtml(
      '<style>/* </style><script>alert(1)</script> */ h1{color:red}</style><p>hi</p>',
    );
    expect(out).not.toContain('<script');
    expect(out).not.toMatch(/on[a-z]+\s*=/i);
  });

  test('</style> inside a CSS string cannot break out into executable markup', () => {
    const out = purifyHtml(
      '<style>h1::before{content:"</style><script>alert(1)</script>"}</style><p>hi</p>',
    );
    expect(out).not.toContain('<script');
  });

  test('script/on*/javascript: are still stripped from markup sitting alongside a styled block', () => {
    const out = purifyHtml(
      '<style>h1{color:red}</style><p onclick="alert(1)">x</p><script>alert(2)</script><a href="javascript:alert(3)">y</a>',
    );
    expect(out).not.toMatch(/onclick/i);
    expect(out).not.toContain('<script');
    expect(out).not.toContain('javascript:');
  });

  test('iframe sandboxing still applies alongside a styled block', () => {
    const out = purifyHtml('<style>h1{color:red}</style><iframe src="https://example.com/"></iframe>');
    expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
  });

  test(
    'documented reality: DOMPurify does not sanitize CSS declaration values -- ' +
      'expression()/behavior/-moz-binding pass through untouched (dead in modern browsers, ' +
      'not exploitable there, but not filtered by this pipeline either)',
    () => {
      const out = purifyHtml(
        '<style>div{width:expression(alert(1));behavior:url(evil.htc);-moz-binding:url(evil.xml#x)}</style><div>x</div>',
      );
      expect(out).toContain('expression(alert(1))');
      expect(out).toContain('behavior:url(evil.htc)');
      expect(out).toContain('-moz-binding:url(evil.xml#x)');
    },
  );

  test('documented reality: url() to a remote host survives (legitimate for background-image, but a known CSS-exfiltration channel already accepted elsewhere in this config)', () => {
    const out = purifyHtml('<style>div{background:url(https://tracker.example/pixel.png)}</style><div>x</div>');
    expect(out).toContain('tracker.example');
  });
});

describe('purifyHtml -- review finding: raw byte-diff against HtmlBlock.tsx@6a9b227 (the commit before Task 25)', () => {
  // Round 1 of this task's tests used `.not.toContain(...)`/`.toContain(...)`
  // assertions for the "no <style> => unchanged" guarantee. Those all
  // passed while FORCE_BODY: true (applied unconditionally at the time)
  // was silently changing the ACTUAL bytes for any style-free block that
  // starts with a multi-line HTML comment -- including this repo's own
  // fixture, which is exactly that shape. `.not.toContain` can't catch an
  // extra leading newline; only a raw diff against the real old output
  // can. These tests do that: `preTask25FixtureOutput` is
  // `purifyHtml(fixtureHtml)` computed with the UNMODIFIED HtmlBlock.tsx
  // source at 6a9b227 (via `git show 6a9b227:...`), run against the real
  // dompurify+jsdom, not re-derived from reading the code -- see that
  // fixture file's own header comment.
  test('the fixture (comment-led, no <style>) is byte-identical to the pre-Task-25 output', () => {
    expect(fixtureHtml.startsWith('<!--')).toBe(true); // sanity: this IS the comment-led shape
    expect(purifyHtml(fixtureHtml)).toBe(preTask25FixtureOutput);
  });

  test('a short comment-led, style-free block matches pre-Task-25 output exactly (including the dropped leading whitespace quirk)', () => {
    // Confirmed independently against 6a9b227's exact code: a multi-line
    // leading comment followed by blank-line whitespace, with no <style>
    // anywhere, produces "<p>hi</p>" -- both the comment AND the
    // whitespace between it and <p> are dropped by the parser's
    // "before head" insertion-mode rules (unrelated to this task; that's
    // the pre-existing, unconditional behavior with FORCE_BODY off). The
    // point of this test is that the NEW code must reproduce that exact
    // old quirk byte-for-byte for style-free input, not "improve" on it.
    const commentLed =
      '<!-- ============================================================\n' +
      '     HTML test fixture header\n' +
      '     ============================================================ -->\n' +
      '\n<p>hi</p>';
    expect(purifyHtml(commentLed)).toBe('<p>hi</p>');
  });

  test('plain style-free inputs (no comment involved) still match pre-Task-25 output', () => {
    expect(purifyHtml('<p>hello</p>')).toBe('<p>hello</p>');
    expect(purifyHtml('<p style="color: #ff0000">red text</p>')).toBe('<p style="color: #ff0000">red text</p>');
  });
});

describe('purifyHtml -- review finding: never throws, even on pathological deeply-nested @media input', () => {
  function buildDeeplyNestedMedia(count: number): string {
    // ~7000 nested @media blocks (the review's exact repro shape) reproduced
    // through the REAL purifyHtml() call, not just scopeCss() in isolation
    // -- proving the fix holds end-to-end through DOMPurify + scopeStyleBlocks,
    // not merely in the unit-tested function.
    let css = 'h1{color:red}';
    for (let i = 0; i < count; i++) css = `@media (min-width: 1px) {${css}}`;
    return `<style>${css}</style><h1>x</h1>`;
  }

  test('~7000 levels of nested @media does not crash purifyHtml (was: RangeError: Maximum call stack size exceeded)', () => {
    const code = buildDeeplyNestedMedia(7000);
    expect(() => purifyHtml(code)).not.toThrow();
    const out = purifyHtml(code);
    expect(out).toContain('<h1>x</h1>');
    expect(out).toContain('@media');
  });

  test('a scope class + wrapper is still produced for the pathological input (best-effort, not a silent no-op)', () => {
    const code = buildDeeplyNestedMedia(7000);
    const out = purifyHtml(code);
    expect(out).toMatch(/^<div class="whp-html-[0-9a-z]+">/);
  });
});

describe('purifyHtml -- review finding: idempotent over its own prior output', () => {
  test('running purifyHtml() twice (customer pastes previously-published output into a fresh block) does not nest a second wrapper', () => {
    const code = '<style>h1 { color: red; }</style><h1>Hi</h1>';
    const once = purifyHtml(code);
    const twice = purifyHtml(once);
    expect(twice).toBe(once);
    // Specifically: no second wrapper div, no double-prefixed selector.
    expect((twice.match(/<div class="whp-html-/g) || []).length).toBe(1);
  });

  test('idempotent for a block using :root/media too', () => {
    const code = '<style>:root{--x:1} @media (min-width: 600px) { h1, p { color: red; } }</style><h1>Hi</h1><p>x</p>';
    const once = purifyHtml(code);
    const twice = purifyHtml(once);
    expect(twice).toBe(once);
    expect((twice.match(/<div class="whp-html-/g) || []).length).toBe(1);
  });

  test('three generations (paste published output into a block, publish again, paste THAT) stay stable', () => {
    const code = '<style>h1{color:red}</style><h1>Hi</h1>';
    const gen1 = purifyHtml(code);
    const gen2 = purifyHtml(gen1);
    const gen3 = purifyHtml(gen2);
    expect(gen3).toBe(gen1);
  });
});
