import { describe, test, expect } from 'vitest';
import { exportBodyHtml, exportToHtml, buildAnimationScript, ExportOptions } from './html-export';
import { DEFAULT_SITE_DESIGN, SiteDesign } from '../state/SiteDesignContext';

/**
 * C2: buildDataAttrs (internal to html-export.ts) previously interpolated
 * `props.animation` / `props.animationDelay` directly into
 * `data-animation="${...}"` / `data-animation-delay="${...}"` with no
 * escaping. Runs for EVERY node (via renderNode -> buildDataAttrs ->
 * injectAttrs) and is reachable via AI `update_props` + deserialized saved
 * state. A malicious value like `x"><img src=y onerror=alert(1)>` breaks
 * out of the attribute and injects a live element.
 */
describe('buildDataAttrs escapes animation/animationDelay (C2)', () => {
  const makeState = (props: Record<string, unknown>) =>
    JSON.stringify({
      ROOT: {
        type: { resolvedName: 'Container' },
        isCanvas: true,
        props: { tag: 'div', style: {}, ...props },
        displayName: 'Container',
        custom: {},
        hidden: false,
        nodes: [],
        linkedNodes: {},
      },
    });

  test('malicious animation value is escaped -- no raw breakout, no injected <img>', () => {
    const payload = 'x"><img src=y onerror=alert(1)>';
    const state = makeState({ animation: payload });
    const { html } = exportBodyHtml(state);

    expect(html).not.toContain('<img src=y onerror=alert(1)>');
    expect(html).not.toMatch(/data-animation="x">/);
    // The attribute value must be escaped, so no raw `"` or `<` survives
    // inside the data-animation attribute region.
    expect(html).toContain('data-animation="x&quot;&gt;&lt;img src=y onerror=alert(1)&gt;"');
  });

  test('malicious animationDelay value is escaped -- no raw breakout', () => {
    const payload = 'x"><script>alert(1)</script>';
    const state = makeState({ animation: 'fade-in', animationDelay: payload });
    const { html } = exportBodyHtml(state);

    expect(html).not.toContain('<script>alert(1)</script>');
    expect(html).toContain('data-animation-delay="x&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;"');
  });

  test('normal animation value still emits data-animation="fade-in" unchanged', () => {
    const state = makeState({ animation: 'fade-in' });
    const { html } = exportBodyHtml(state);
    expect(html).toContain('data-animation="fade-in"');
  });

  test('normal animationDelay value still emits unchanged', () => {
    const state = makeState({ animation: 'fade-in', animationDelay: '0.5s' });
    const { html } = exportBodyHtml(state);
    expect(html).toContain('data-animation-delay="0.5s"');
  });
});

/**
 * Adversarial re-review of C1: the `typeName === 'Container' || typeName ===
 * 'div'` fallback in renderNode (hit when a node's `resolvedName` isn't in
 * the component resolver, e.g. legacy/tampered saved state) interpolated
 * `props.tag` raw into `<${tag}` with no validation -- the same breakout
 * class as the fixed Container.toHtml bug, reachable via deserialized saved
 * state.
 */
describe('renderNode div-fallback allowlists props.tag', () => {
  const makeUnresolvedDivState = (tag: unknown) =>
    JSON.stringify({
      ROOT: {
        type: { resolvedName: 'div' }, // not in componentResolver -> hits the fallback branch
        isCanvas: true,
        props: { tag, style: {} },
        displayName: 'div',
        custom: {},
        hidden: false,
        nodes: [],
        linkedNodes: {},
      },
    });

  test('a malicious tag value falls back to div -- no injected <img>', () => {
    const state = makeUnresolvedDivState('div><img src=x onerror=alert(1)');
    const { html } = exportBodyHtml(state);
    expect(html).not.toContain('<img');
    expect(html).not.toContain('onerror');
    expect(html.startsWith('<div')).toBe(true);
  });

  test('a valid tag (section) still emits <section', () => {
    const state = makeUnresolvedDivState('section');
    const { html } = exportBodyHtml(state);
    expect(html).toContain('<section');
    expect(html).toContain('</section>');
  });
});

/**
 * PKG-H contract §2-4: per-page SEO/meta, favicon, and design-token CSS
 * variables emitted into `wrapInDocument`'s <head> (exercised via
 * `exportToHtml`, since `exportBodyHtml` intentionally stays body-only --
 * contract §7). These assert the exact "only emit when set" / sanitization
 * / ordering rules the backend implementer's `generateCompiledHTML` must
 * mirror byte-for-byte for editor Preview === published output.
 */
describe('PKG-H: SEO/meta + favicon + design-token <head> emission', () => {
  const EMPTY_ROOT_STATE = JSON.stringify({
    ROOT: {
      type: { resolvedName: 'Container' },
      isCanvas: true,
      props: { style: {}, tag: 'div' },
      displayName: 'Container',
      custom: {},
      hidden: false,
      nodes: [],
      linkedNodes: {},
    },
  });

  function exportWith(options: ExportOptions): string {
    return exportToHtml(EMPTY_ROOT_STATE, options).html;
  }

  describe('back-compat: no seo, no favicon, no design passed', () => {
    const html = exportWith({ title: 'Legacy Page' });

    test('title falls back to the passed title, no robots/description/og/twitter/favicon tags appear', () => {
      expect(html).toContain('<title>Legacy Page</title>');
      expect(html).not.toContain('name="robots"');
      expect(html).not.toContain('name="description"');
      expect(html).not.toContain('property="og:description"');
      expect(html).not.toContain('property="og:image"');
      expect(html).not.toContain('rel="icon"');
      // og:title still always emits (falls back to title) -- og:type is a
      // fixed tag too -- neither depends on seo being set.
      expect(html).toContain('<meta property="og:title" content="Legacy Page">');
      expect(html).toContain('<meta property="og:type" content="website">');
      // twitter:card only emits when ogImage or description is set -- neither
      // is here, so it must be absent.
      expect(html).not.toContain('name="twitter:card"');
    });

    test('still emits the :root token block + token-base CSS + a11y/perf CSS using design defaults (§7 intended change)', () => {
      expect(html).toContain(':root{--wsb-primary:');
      expect(html).toContain('--wsb-button-radius:');
      expect(html).toContain('body{font-family:var(--wsb-body-font)');
      expect(html).toContain('a:focus-visible');
      expect(html).toContain('prefers-reduced-motion:reduce');
    });
  });

  describe('meta/og/twitter/robots/favicon emit ONLY when their source is set', () => {
    test('metaDescription set -> description + og:description + twitter:card (summary) all appear', () => {
      const html = exportWith({ title: 'Page', description: 'A great page.' });
      expect(html).toContain('<meta name="description" content="A great page.">');
      expect(html).toContain('<meta property="og:description" content="A great page.">');
      expect(html).toContain('<meta name="twitter:card" content="summary">');
    });

    test('ogImage set (no description) -> og:image + twitter:card defaults to summary_large_image', () => {
      const html = exportWith({ title: 'Page', ogImage: '/uploads/hero.jpg' });
      expect(html).toContain('<meta property="og:image" content="/uploads/hero.jpg">');
      expect(html).toContain('<meta name="twitter:card" content="summary_large_image">');
      expect(html).not.toContain('name="description"');
    });

    test('explicit twitterCard overrides the derived default', () => {
      const html = exportWith({ title: 'Page', ogImage: '/uploads/hero.jpg', twitterCard: 'summary' });
      expect(html).toContain('<meta name="twitter:card" content="summary">');
    });

    test('ogTitle set -> og:title uses it instead of the page title', () => {
      const html = exportWith({ title: 'Page Title', ogTitle: 'Custom Share Title' });
      expect(html).toContain('<meta property="og:title" content="Custom Share Title">');
      expect(html).not.toContain('<meta property="og:title" content="Page Title">');
    });

    test('noindex true -> robots meta appears; false/absent -> it does not', () => {
      expect(exportWith({ title: 'Page', noindex: true })).toContain('<meta name="robots" content="noindex, nofollow">');
      expect(exportWith({ title: 'Page', noindex: false })).not.toContain('name="robots"');
      expect(exportWith({ title: 'Page' })).not.toContain('name="robots"');
    });

    test('favicon set -> icon link appears with the exact URL', () => {
      const html = exportWith({ title: 'Page', favicon: '/uploads/favicon.png' });
      expect(html).toContain('<link rel="icon" href="/uploads/favicon.png">');
    });

    test('favicon absent -> no icon link', () => {
      expect(exportWith({ title: 'Page' })).not.toContain('rel="icon"');
    });
  });

  describe('sanitization neutralizes malicious values', () => {
    test('a javascript: og:image / favicon URL is dropped entirely (no tag emitted)', () => {
      const html = exportWith({
        title: 'Page',
        ogImage: 'javascript:alert(1)',
        favicon: 'javascript:alert(1)',
        description: 'x', // keep twitter:card path alive to prove ogImage really resolved empty
      });
      expect(html).not.toContain('javascript:alert');
      expect(html).not.toContain('property="og:image"');
      expect(html).not.toContain('rel="icon"');
      // og:image resolved empty -> twitter:card falls back to 'summary' (description-only path), not 'summary_large_image'.
      expect(html).toContain('<meta name="twitter:card" content="summary">');
    });

    test('a data:image/png og:image URL is allowed through unchanged (image sink)', () => {
      const html = exportWith({ title: 'Page', ogImage: 'data:image/png;base64,AAAA' });
      expect(html).toContain('<meta property="og:image" content="data:image/png;base64,AAAA">');
    });

    test('an attribute-breakout metaDescription is escaped, not left raw', () => {
      const payload = '"><script>alert(1)</script>';
      const html = exportWith({ title: 'Page', description: payload });
      expect(html).not.toContain('<script>alert(1)</script>');
      expect(html).toContain('&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;');
    });

    test('a malicious design-token value cannot break out of the :root{} block', () => {
      const design: SiteDesign = { ...DEFAULT_SITE_DESIGN, primaryColor: '</style><script>alert(1)</script>' };
      const html = exportWith({ title: 'Page', design });
      expect(html).not.toContain('<script>alert(1)</script>');
      expect(html).not.toContain('</style><script>');
    });
  });

  describe(':root token block', () => {
    test('emits all 16 contract-named CSS variables with sanitized values, in order', () => {
      const design: SiteDesign = {
        ...DEFAULT_SITE_DESIGN,
        primaryColor: '#111111',
        secondaryColor: '#222222',
        accentColor: '#333333',
        linkColor: '#444444',
        successColor: '#555555',
        warningColor: '#666666',
        errorColor: '#777777',
        backgroundColor: '#888888',
        textColor: '#999999',
        mutedTextColor: '#aaaaaa',
        borderColor: '#bbbbbb',
        borderRadius: '12px',
        headingFont: 'Georgia, serif',
        bodyFont: 'Verdana, sans-serif',
        buttonFont: 'Tahoma, sans-serif',
        buttonRadius: '4px',
      };
      const html = exportWith({ title: 'Page', design });

      const rootMatch = html.match(/:root\{([^}]*)\}/);
      expect(rootMatch).not.toBeNull();
      const rootBlock = rootMatch![1];

      const expectedOrder = [
        ['--wsb-primary', '#111111'],
        ['--wsb-secondary', '#222222'],
        ['--wsb-accent', '#333333'],
        ['--wsb-link', '#444444'],
        ['--wsb-success', '#555555'],
        ['--wsb-warning', '#666666'],
        ['--wsb-error', '#777777'],
        ['--wsb-bg', '#888888'],
        ['--wsb-text', '#999999'],
        ['--wsb-muted', '#aaaaaa'],
        ['--wsb-border', '#bbbbbb'],
        ['--wsb-radius', '12px'],
        ['--wsb-heading-font', 'Georgia, serif'],
        ['--wsb-body-font', 'Verdana, sans-serif'],
        ['--wsb-button-font', 'Tahoma, sans-serif'],
        ['--wsb-button-radius', '4px'],
      ];

      let lastIndex = -1;
      for (const [varName, value] of expectedOrder) {
        const decl = `${varName}:${value}`;
        expect(rootBlock).toContain(decl);
        const idx = rootBlock.indexOf(decl);
        expect(idx).toBeGreaterThan(lastIndex);
        lastIndex = idx;
      }
    });
  });

  describe('a11y/perf CSS', () => {
    test('focus-visible outline rule is present', () => {
      const html = exportWith({ title: 'Page' });
      expect(html).toContain('a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}');
    });

    test('prefers-reduced-motion rule is present', () => {
      const html = exportWith({ title: 'Page' });
      expect(html).toContain('@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}');
    });
  });

  describe('Google Fonts link', () => {
    test('always includes display=swap, with no design passed (fallback full link)', () => {
      const html = exportWith({ title: 'Page', includeFonts: true });
      expect(html).toContain('display=swap');
      expect(html).toContain('fonts.googleapis.com');
    });

    test('subsets to only the used families when all 3 design fonts are known presets', () => {
      const design: SiteDesign = {
        ...DEFAULT_SITE_DESIGN,
        headingFont: 'Playfair Display, serif',
        bodyFont: 'Inter, sans-serif',
        buttonFont: 'Inter, sans-serif',
      };
      const html = exportWith({ title: 'Page', includeFonts: true, design });
      expect(html).toContain('display=swap');
      expect(html).toContain('family=Playfair+Display');
      expect(html).toContain('family=Inter');
      // Not one of the 3 used fonts -- subset link must not pull it in.
      expect(html).not.toContain('family=Merriweather');
    });

    test('falls back to the full multi-font link when a design font is not a known preset', () => {
      const design: SiteDesign = {
        ...DEFAULT_SITE_DESIGN,
        headingFont: 'Comic Sans MS, cursive',
      };
      const html = exportWith({ title: 'Page', includeFonts: true, design });
      expect(html).toContain('display=swap');
      // Full fallback link carries every preset family, including ones the
      // design object doesn't reference -- a component using ANY preset
      // never silently loses its font.
      expect(html).toContain('family=Merriweather');
    });
  });
});

/**
 * FIX: entrance-animation broken in Preview. Root causes (see
 * .superpowers/sdd/fix-animation-contract.md):
 *   1. injectAttrs inserted data-attrs before the tag's first `>`, so a void
 *      tag (`<img ... />`) became malformed (`<img ... / data-animation="...">`,
 *      attrs landing AFTER the self-close slash, outside the tag).
 *   2. wrapInDocument's in-body reveal <script> was destroyed by TopBar's
 *      handlePreview, which replaces the whole <body> inner with a
 *      recomposed header+body+footer string that never carried the script.
 */
describe('injectAttrs well-formed void-tag attrs (animation fix)', () => {
  // ImageBlock.toHtml renders `<img src="..." ... />` -- a real void-tag
  // producer that goes through injectAttrs via renderNode.
  const imageState = (props: Record<string, unknown>) =>
    JSON.stringify({
      ROOT: {
        type: { resolvedName: 'ImageBlock' },
        isCanvas: false,
        props: { src: '/uploads/photo.jpg', style: {}, ...props },
        displayName: 'ImageBlock',
        custom: {},
        hidden: false,
        nodes: [],
        linkedNodes: {},
      },
    });

  test('void <img/> gets attrs INSIDE the tag, no " / " sequence before the final >', () => {
    const { html } = exportBodyHtml(imageState({ animation: 'bounce' }));
    expect(html).toContain('data-animation="bounce"');
    // Well-formed: the attribute sits before the self-close slash.
    expect(html).toMatch(/data-animation="bounce"\s*\/>/);
    // Malformed shape from the bug: attrs landing after the slash.
    expect(html).not.toMatch(/\/\s*data-animation="bounce"/);
    expect(html).not.toContain('/ data-animation');
  });

  test('non-void tag (Container div) is unaffected -- attrs still inserted before its only >', () => {
    const state = JSON.stringify({
      ROOT: {
        type: { resolvedName: 'Container' },
        isCanvas: true,
        props: { tag: 'div', style: {}, animation: 'fade-in' },
        displayName: 'Container',
        custom: {},
        hidden: false,
        nodes: [],
        linkedNodes: {},
      },
    });
    const { html } = exportBodyHtml(state);
    expect(html).toMatch(/^<div[^>]*data-animation="fade-in"[^>]*>/);
    expect(html).not.toContain('/>');
  });
});

describe('buildAnimationScript (animation fix)', () => {
  test('returns the IntersectionObserver reveal script when body contains data-animation', () => {
    const body = '<div data-animation="fade-in">Hi</div>';
    const script = buildAnimationScript(body);
    expect(script).toContain('<script>');
    expect(script).toContain('IntersectionObserver');
    expect(script).toContain("querySelectorAll('[data-animation]')");
  });

  test('returns empty string when body has no data-animation', () => {
    expect(buildAnimationScript('<div>Hi</div>')).toBe('');
  });

  test('reveal script coerces a bare-number delay to a valid CSS time (e.g. "2" -> "2s")', () => {
    // animationDelay is stored as a plain seconds string ("2"); assigning that raw
    // to el.style.animationDelay is invalid CSS and no-ops. The script must suffix a
    // unit onto bare numbers while leaving unit-bearing values ("2s"/"200ms") alone.
    const script = buildAnimationScript('<div data-animation="fade-in" data-animation-delay="2">Hi</div>');
    expect(script).toContain("/^-?[0-9.]+$/.test(delay) ? delay + 's' : delay");
    // guard against regressing to the raw (invalid) assignment
    expect(script).not.toContain('animationDelay = delay;');
    // sanity-check the coercion logic itself against representative inputs
    const coerce = (delay: string) => (/^-?[0-9.]+$/.test(delay) ? delay + 's' : delay);
    expect(coerce('2')).toBe('2s');
    expect(coerce('0.5')).toBe('0.5s');
    expect(coerce('2s')).toBe('2s');
    expect(coerce('200ms')).toBe('200ms');
  });
});

describe('Preview body-replacement keeps exactly one reveal script (animation fix)', () => {
  const animatedState = JSON.stringify({
    ROOT: {
      type: { resolvedName: 'Container' },
      isCanvas: true,
      props: { tag: 'div', style: {}, animation: 'fade-in' },
      displayName: 'Container',
      custom: {},
      hidden: false,
      nodes: [],
      linkedNodes: {},
    },
  });

  test('wrapped doc alone already contains exactly one script + the CSS + the noscript fallback', () => {
    const { html } = exportToHtml(animatedState, { title: 'Page' });
    const scriptCount = (html.match(/IntersectionObserver/g) || []).length;
    expect(scriptCount).toBe(1);
    expect(html).toContain('[data-animation]{opacity:0}');
    expect(html).toContain('<noscript><style>[data-animation]{opacity:1}</style></noscript>');
  });

  test('simulated handlePreview body-replacement: composed body built WITH buildAnimationScript still yields exactly one reveal script and the head CSS survives', () => {
    // Mirror TopBar.tsx handlePreview: exportToHtml gives the wrapped doc
    // (head CSS/noscript + its own in-body script); a "composedBody" of
    // header+body+footer (no script of its own) is what actually replaces
    // the <body> inner. Without appending buildAnimationScript to
    // composedBody, the wrapped doc's script would be clobbered and the
    // element would never reveal.
    const { html: wrapped } = exportToHtml(animatedState, { title: 'Page' });
    const headerHtml = '';
    const { html: bodyHtml } = exportBodyHtml(animatedState);
    const footerHtml = '';
    const composedBody =
      headerHtml + bodyHtml + footerHtml +
      buildAnimationScript(headerHtml + bodyHtml + footerHtml);

    const bodyMatch = wrapped.match(/<body[^>]*>([\s\S]*)<\/body>/i);
    expect(bodyMatch).toBeTruthy();
    const finalHtml = wrapped.replace(bodyMatch![1], () => composedBody);

    const scriptCount = (finalHtml.match(/IntersectionObserver/g) || []).length;
    expect(scriptCount).toBe(1);
    expect(finalHtml).toContain('[data-animation]{opacity:0}');
    expect(finalHtml).toContain('data-animation="fade-in"');
    // No malformed void-tag artifact should leak into the final assembly.
    expect(finalHtml).not.toContain('/ data-animation');
  });

  test('non-animated body: no animation CSS, no noscript, no reveal script anywhere', () => {
    const plainState = JSON.stringify({
      ROOT: {
        type: { resolvedName: 'Container' },
        isCanvas: true,
        props: { tag: 'div', style: {} },
        displayName: 'Container',
        custom: {},
        hidden: false,
        nodes: [],
        linkedNodes: {},
      },
    });
    const { html } = exportToHtml(plainState, { title: 'Page' });
    expect(html).not.toContain('[data-animation]');
    expect(html).not.toContain('<noscript>');
    expect(html).not.toContain('IntersectionObserver');
    expect(buildAnimationScript(exportBodyHtml(plainState).html)).toBe('');
  });
});

/**
 * FIX: bounce entrance-animation disappears after finishing + reads like a
 * fade (see .superpowers/sdd/fix-anim-image-contract.md, section A). Root
 * cause: the old `@keyframes bounce` set opacity at 0% and 60% but NOT at
 * 100% -- with `animation-fill-mode: both`, on finish the element reverted
 * to the base `[data-animation]{opacity:0}` rule and vanished. The fix is a
 * springier keyframe that ends at `opacity:1`.
 */
describe('bounce keyframe ends at opacity:1 (fix-anim-image A)', () => {
  const animatedState = (animation: string) => JSON.stringify({
    ROOT: {
      type: { resolvedName: 'Container' },
      isCanvas: true,
      props: { tag: 'div', style: {}, animation },
      displayName: 'Container',
      custom: {},
      hidden: false,
      nodes: [],
      linkedNodes: {},
    },
  });

  const NEW_BOUNCE_MINIFIED = '@keyframes bounce{0%{opacity:0;transform:translateY(40px)}40%{opacity:1;transform:translateY(-12px)}60%{transform:translateY(6px)}80%{transform:translateY(-3px)}100%{opacity:1;transform:translateY(0)}}';
  const OLD_BOUNCE_TAIL = '100%{transform:translateY(0)}}';

  test('minified export contains the new springier bounce substring, byte-identical to the shared contract', () => {
    const { html } = exportToHtml(animatedState('bounce'), { title: 'Page' });
    expect(html).toContain(NEW_BOUNCE_MINIFIED);
  });

  test('minified export does NOT contain the old bounce tail (100% with no opacity)', () => {
    const { html } = exportToHtml(animatedState('bounce'), { title: 'Page' });
    expect(html).not.toContain(OLD_BOUNCE_TAIL);
    // Every keyframe's 100% frame in this doc must carry opacity:1 now.
    expect(html).toContain('100%{opacity:1;transform:translateY(0)}}');
  });

  test('pretty (non-minified) export ends the bounce keyframe at 100% { opacity: 1; transform: translateY(0); }', () => {
    const { html } = exportToHtml(animatedState('bounce'), { title: 'Page', minifyCss: false });
    const NEW_BOUNCE_PRETTY = '@keyframes bounce { 0% { opacity: 0; transform: translateY(40px); } 40% { opacity: 1; transform: translateY(-12px); } 60% { transform: translateY(6px); } 80% { transform: translateY(-3px); } 100% { opacity: 1; transform: translateY(0); } }';
    expect(html).toContain(NEW_BOUNCE_PRETTY);
    expect(html).not.toContain('100% { transform: translateY(0); } }');
  });

  test('other keyframes (fadeIn/slideUp/zoomIn) are unchanged', () => {
    const { html } = exportToHtml(animatedState('bounce'), { title: 'Page' });
    expect(html).toContain('@keyframes fadeIn{from{opacity:0}to{opacity:1}}');
    expect(html).toContain('@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}');
    expect(html).toContain('@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}');
  });
});
