import { describe, test, expect } from 'vitest';
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, stableHash, scopeId, sanitizeFormMethod, sanitizeInputType } from './escape';

describe('escapeHtml', () => {
  test('escapes &, <, >, "', () => {
    expect(escapeHtml('a & b <c> "d"')).toBe('a &amp; b &lt;c&gt; &quot;d&quot;');
  });

  test('escapes ampersand first (no double-escaping)', () => {
    expect(escapeHtml('&amp;')).toBe('&amp;amp;');
  });

  test('coerces non-string / null / undefined safely', () => {
    expect(escapeHtml(null as any)).toBe('');
    expect(escapeHtml(undefined as any)).toBe('');
    expect(escapeHtml(123 as any)).toBe('123');
  });
});

describe('escapeAttr', () => {
  test('does everything escapeHtml does', () => {
    expect(escapeAttr('a & b <c> "d"')).toBe('a &amp; b &lt;c&gt; &quot;d&quot;');
  });

  test('also escapes single quote and backtick', () => {
    expect(escapeAttr(`'`)).toBe('&#39;');
    expect(escapeAttr('`')).toBe('&#96;');
  });

  test('combined example', () => {
    expect(escapeAttr(`it's a "test" <b>\``)).toBe('it&#39;s a &quot;test&quot; &lt;b&gt;&#96;');
  });

  test('coerces non-string / null / undefined safely', () => {
    expect(escapeAttr(null as any)).toBe('');
    expect(escapeAttr(undefined as any)).toBe('');
  });
});

describe('safeUrl', () => {
  test('blocks javascript: scheme', () => {
    expect(safeUrl('javascript:alert(1)')).toBe('');
  });

  test('blocks javascript: with leading whitespace and mixed case', () => {
    expect(safeUrl('  JavaScript:alert(1)')).toBe('');
  });

  test('blocks javascript: with tab/control-char obfuscation', () => {
    expect(safeUrl('java\tscript:alert(1)')).toBe('');
  });

  test('blocks entity-obfuscated javascript: scheme (decimal)', () => {
    expect(safeUrl('&#106;avascript:alert(1)')).toBe('');
  });

  test('blocks entity-obfuscated javascript: scheme (zero-padded decimal)', () => {
    expect(safeUrl('java&#0000058script:alert(1)')).toBe('');
  });

  test('blocks vbscript: scheme', () => {
    expect(safeUrl('vbscript:msgbox(1)')).toBe('');
  });

  test('blocks data:text/html', () => {
    expect(safeUrl('data:text/html;base64,PHNjcmlwdD4=')).toBe('');
  });

  test('allows data:image/png', () => {
    const s = 'data:image/png;base64,iVBORw0KGgo=';
    expect(safeUrl(s)).toBe(s);
  });

  test('M-5: blocks data:image/svg+xml (can execute script when navigated to directly)', () => {
    expect(safeUrl('data:image/svg+xml,<svg onload=alert(1)>')).toBe('');
  });

  test('M-5: blocks data:image/svg+xml;base64 variant', () => {
    expect(safeUrl('data:image/svg+xml;base64,PHN2ZyBvbmxvYWQ9YWxlcnQoMSk+')).toBe('');
  });

  test('M-5: blocks obfuscated (whitespace/case) data:image/svg+xml', () => {
    expect(safeUrl('  DATA:IMAGE/SVG+XML,<svg onload=alert(1)>')).toBe('');
  });

  test('M-5: still allows other data:image/* types unchanged', () => {
    expect(safeUrl('data:image/jpeg;base64,/9j/4AAQ')).toBe('data:image/jpeg;base64,/9j/4AAQ');
    expect(safeUrl('data:image/gif;base64,R0lGOD')).toBe('data:image/gif;base64,R0lGOD');
    expect(safeUrl('data:image/webp;base64,UklGR')).toBe('data:image/webp;base64,UklGR');
  });

  test.each([
    'https://x.com/a?b=1&c=2',
    'http://x',
    '/relative/path',
    '#anchor',
    'mailto:a@b.com',
    'tel:+15551234',
    '',
  ])('allows %s unchanged (trimmed)', (input) => {
    expect(safeUrl(input)).toBe(input.trim());
  });

  test('coerces non-string to empty string', () => {
    expect(safeUrl(null as any)).toBe('');
    expect(safeUrl(undefined as any)).toBe('');
    expect(safeUrl(123 as any)).toBe('');
  });

  test('does not mangle query strings on allowed urls', () => {
    expect(safeUrl('https://x.com/a?b=1&c=2')).toBe('https://x.com/a?b=1&c=2');
  });
});

describe('safeImageUrl (Bug 2: image-context sink -- data:image/svg+xml is safe as an <img>/CSS url() target)', () => {
  test('allows data:image/svg+xml (a raw, non-base64 SVG data URI, as Gallery/ImageBlock placeholders use)', () => {
    const s = 'data:image/svg+xml,<svg/>';
    expect(safeImageUrl(s)).toBe(s);
  });

  test('allows data:image/svg+xml;base64 variant', () => {
    const s = 'data:image/svg+xml;base64,PHN2Zy8+';
    expect(safeImageUrl(s)).toBe(s);
  });

  test('allows other data:image/* types unchanged', () => {
    expect(safeImageUrl('data:image/png;base64,x')).toBe('data:image/png;base64,x');
    expect(safeImageUrl('data:image/jpeg;base64,x')).toBe('data:image/jpeg;base64,x');
    expect(safeImageUrl('data:image/webp;base64,x')).toBe('data:image/webp;base64,x');
    expect(safeImageUrl('data:image/gif;base64,x')).toBe('data:image/gif;base64,x');
  });

  test('still blocks javascript: scheme', () => {
    expect(safeImageUrl('javascript:alert(1)')).toBe('');
  });

  test('still blocks vbscript: scheme', () => {
    expect(safeImageUrl('vbscript:msgbox(1)')).toBe('');
  });

  test('still blocks data:text/html', () => {
    expect(safeImageUrl('data:text/html,x')).toBe('');
  });

  test('still blocks non-image data: types generally (e.g. data:application/...)', () => {
    expect(safeImageUrl('data:application/javascript,alert(1)')).toBe('');
  });

  test('blocks obfuscated (whitespace/case) javascript: scheme, same as safeUrl', () => {
    expect(safeImageUrl('  JavaScript:alert(1)')).toBe('');
    expect(safeImageUrl('java\tscript:alert(1)')).toBe('');
    expect(safeImageUrl('&#106;avascript:alert(1)')).toBe('');
  });

  test('allows ordinary http(s)/relative urls unchanged, same as safeUrl', () => {
    expect(safeImageUrl('https://example.com/photo.jpg')).toBe('https://example.com/photo.jpg');
    expect(safeImageUrl('/assets/photo.jpg')).toBe('/assets/photo.jpg');
    expect(safeImageUrl('')).toBe('');
  });

  test('coerces non-string to empty string', () => {
    expect(safeImageUrl(null as any)).toBe('');
    expect(safeImageUrl(undefined as any)).toBe('');
  });

  test('tightened allowlist: blocks a bogus MIME that merely starts with "image" but has no slash (e.g. data:imagehtml/...)', () => {
    expect(safeImageUrl('data:imagehtml/svg+xml,x')).toBe('');
  });

  test('tightened allowlist: still allows legit data:image/* subtypes', () => {
    expect(safeImageUrl('data:image/png;base64,x')).toBe('data:image/png;base64,x');
    expect(safeImageUrl('data:image/svg+xml,<svg/>')).toBe('data:image/svg+xml,<svg/>');
  });
});

describe('safeUrl still blocks data:image/svg+xml (href/iframe/navigation context unchanged by safeImageUrl addition)', () => {
  test('safeUrl(data:image/svg+xml,...) is still blocked', () => {
    expect(safeUrl('data:image/svg+xml,<svg onload=alert(1)>')).toBe('');
  });
});

describe('stableHash', () => {
  test('same input always produces the same output', () => {
    expect(stableHash('hello')).toBe(stableHash('hello'));
  });

  test('different inputs produce different output (no collision for these cases)', () => {
    expect(stableHash('hello')).not.toBe(stableHash('world'));
  });

  test('returns a non-empty string', () => {
    expect(stableHash('')).toEqual(expect.any(String));
    expect(stableHash('x').length).toBeGreaterThan(0);
  });
});

describe('scopeId', () => {
  test('same node id -> identical scope id across calls (deterministic)', () => {
    expect(scopeId('node-abc', 'seed', 'sb')).toBe(scopeId('node-abc', 'seed', 'sb'));
  });

  test('different node ids -> different, non-colliding scope ids even with the same fallback seed', () => {
    const id1 = scopeId('node-a', 'same-seed', 'sb');
    const id2 = scopeId('node-b', 'same-seed', 'sb');
    expect(id1).not.toBe(id2);
  });

  test('output is a valid CSS ident: prefix_hash', () => {
    expect(scopeId('Node ID! 123', 'seed', 'sb')).toMatch(/^sb_[a-z0-9]+$/);
  });

  test('M-4: case-differing node ids do not collapse to the same scope (no case-fold collision)', () => {
    expect(scopeId('AbC', 'seed', 'sb')).not.toBe(scopeId('abc', 'seed', 'sb'));
  });

  test('M-4: punctuation-differing node ids do not collapse to the same scope', () => {
    expect(scopeId('a-b', 'seed', 'sb')).not.toBe(scopeId('ab', 'seed', 'sb'));
  });

  test('M-4: same input always yields the identical scope id', () => {
    expect(scopeId('some-node-id', 'seed', 'sb')).toBe(scopeId('some-node-id', 'seed', 'sb'));
  });

  test('M-4: output always matches a valid CSS ident pattern', () => {
    expect(scopeId('Weird!! Node--ID__123', 'seed', 'sb')).toMatch(/^[a-z]+_[a-z0-9]+$/i);
  });

  test('no nodeId (legacy 2-arg call sites) falls back to a deterministic hash of the seed, not Math.random', () => {
    const id1 = scopeId(undefined, 'same-seed', 'sb');
    const id2 = scopeId(undefined, 'same-seed', 'sb');
    expect(id1).toBe(id2);
  });

  test('no nodeId: different seeds fall back to different hashes', () => {
    const id1 = scopeId(undefined, 'seed-one', 'sb');
    const id2 = scopeId(undefined, 'seed-two', 'sb');
    expect(id1).not.toBe(id2);
  });

  test('prefix is applied', () => {
    expect(scopeId('node-abc', 'seed', 'tabs')).toMatch(/^tabs_/);
  });
});

describe('sanitizeFormMethod', () => {
  test('allows GET and POST unchanged', () => {
    expect(sanitizeFormMethod('GET')).toBe('GET');
    expect(sanitizeFormMethod('POST')).toBe('POST');
  });

  test('is case-insensitive and normalizes to uppercase', () => {
    expect(sanitizeFormMethod('get')).toBe('GET');
    expect(sanitizeFormMethod('post')).toBe('POST');
    expect(sanitizeFormMethod('PoSt')).toBe('POST');
  });

  test('falls back to POST for anything not in the allowlist', () => {
    expect(sanitizeFormMethod('DELETE')).toBe('POST');
    expect(sanitizeFormMethod('')).toBe('POST');
    expect(sanitizeFormMethod(undefined as any)).toBe('POST');
  });

  test('neutralizes an attribute-breakout injection instead of passing it through', () => {
    const malicious = 'POST"><script>alert(1)</script>';
    expect(sanitizeFormMethod(malicious)).toBe('POST');
  });

  test('coerces non-string input safely', () => {
    expect(sanitizeFormMethod(123 as any)).toBe('POST');
    expect(sanitizeFormMethod(null as any)).toBe('POST');
  });
});

describe('sanitizeInputType', () => {
  test('allows known-safe input types unchanged', () => {
    ['text', 'email', 'tel', 'number', 'password', 'url', 'search', 'date', 'checkbox', 'radio', 'hidden'].forEach((t) => {
      expect(sanitizeInputType(t)).toBe(t);
    });
  });

  test('falls back to text for anything not in the allowlist', () => {
    expect(sanitizeInputType('bogus')).toBe('text');
    expect(sanitizeInputType('')).toBe('text');
    expect(sanitizeInputType(undefined as any)).toBe('text');
  });

  test('neutralizes an attribute-breakout injection instead of passing it through', () => {
    const malicious = 'text"><img src=x onerror=alert(1)>';
    expect(sanitizeInputType(malicious)).toBe('text');
  });

  test('coerces non-string input safely', () => {
    expect(sanitizeInputType(123 as any)).toBe('text');
    expect(sanitizeInputType(null as any)).toBe('text');
  });
});
