import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { installMockOpenDesignHost } from '@open-design/host/testing';
import {
  archiveFilenameFrom,
  archiveRootFromFilePath,
  buildDesignHandoffContent,
  buildDesignManifestContent,
  downloadImageDataUrl,
  buildSandboxedPreviewDocument,
  exportAsImage,
  exportAsMd,
  exportAsPdf,
  exportProjectAsPdf,
  openSandboxedPreviewInNewTab,
  prepareImageExportTarget,
  requestPreviewSnapshot,
} from '../../src/runtime/exports';

function mockResponse(headers: Record<string, string>): Response {
  return { headers: new Headers(headers) } as Response;
}

describe('archiveRootFromFilePath', () => {
  it('returns the top-level directory name when present', () => {
    expect(archiveRootFromFilePath('ui-design/index.html')).toBe('ui-design');
    expect(archiveRootFromFilePath('ui-design/src/app.css')).toBe('ui-design');
  });

  it('returns empty for files at the project root', () => {
    expect(archiveRootFromFilePath('index.html')).toBe('');
    expect(archiveRootFromFilePath('README.md')).toBe('');
  });

  it('strips a leading slash before scanning', () => {
    expect(archiveRootFromFilePath('/ui-design/index.html')).toBe('ui-design');
    expect(archiveRootFromFilePath('//ui-design/index.html')).toBe('ui-design');
  });

  it('returns empty for empty/garbage input', () => {
    expect(archiveRootFromFilePath('')).toBe('');
    expect(archiveRootFromFilePath('/')).toBe('');
  });
});

describe('archiveFilenameFrom', () => {
  it('decodes the RFC 5987 UTF-8 filename* form (preserves multi-byte chars)', () => {
    // 'café-design.zip' encoded — the é is a 2-byte UTF-8 sequence (%C3%A9),
    // which is enough to fail under naive ASCII-only handling.
    const resp = mockResponse({
      'content-disposition':
        "attachment; filename=\"project.zip\"; filename*=UTF-8''caf%C3%A9-design.zip",
    });
    expect(archiveFilenameFrom(resp, 'fallback', 'ui-design')).toBe('café-design.zip');
  });

  it('falls back to the legacy quoted filename= when filename* is absent', () => {
    const resp = mockResponse({
      'content-disposition': 'attachment; filename="ui-design.zip"',
    });
    expect(archiveFilenameFrom(resp, 'fallback', 'ui-design')).toBe('ui-design.zip');
  });

  it('falls back to the active root slug when the header is missing', () => {
    const resp = mockResponse({});
    expect(archiveFilenameFrom(resp, 'fallback-title', 'ui-design')).toBe('ui-design.zip');
  });

  it('falls back to the title slug when both header and root are absent', () => {
    const resp = mockResponse({});
    expect(archiveFilenameFrom(resp, 'My Artifact', '')).toBe('My-Artifact.zip');
  });

  it('falls through to the slug when filename* is malformed', () => {
    // Truncated percent-escape — decodeURIComponent throws; we should not
    // surface the exception, just fall back to the next strategy.
    const resp = mockResponse({
      'content-disposition': "attachment; filename*=UTF-8''%E9%9D",
    });
    expect(archiveFilenameFrom(resp, 'fallback', 'ui-design')).toBe('ui-design.zip');
  });
});

describe('buildDesignHandoffContent', () => {
  it('documents coding handoff and responsive verification expectations', () => {
    const content = buildDesignHandoffContent({
      title: 'Checkout Design',
      entryFile: 'index.html',
      files: ['index.html', 'src/app.css', 'src/app.js'],
    });

    expect(content).toContain('Checkout Design implementation handoff');
    expect(content).toContain('Mobile compact: 360×800');
    expect(content).toContain('Tablet portrait: 820×1180');
    expect(content).toContain('Wide desktop: 1920×1080');
    expect(content).toContain('`src/app.css`');
    expect(content).toContain('visual system');
    expect(content).toContain('Design fidelity contract');
    expect(content).toContain('CJX-ready UX contract');
    expect(content).toContain('DESIGN-MANIFEST.json');
    expect(content).toContain('in-app modules/components');
    expect(content).toContain('OS widgets are home-screen/lock-screen/quick-access surfaces');
    expect(content).toContain('Color and brand contract');
    expect(content).toContain('Do not introduce warm beige / cream / peach / pink / orange-brown background washes');
    expect(content).toContain('Build product screens and domain-specific in-app modules');
  });

  it('builds a machine-readable design manifest for coding tools', () => {
    const manifest = JSON.parse(buildDesignManifestContent({
      title: 'Checkout Design',
      entryFile: 'index.html',
      files: ['index.html', 'src/app.css', 'src/app.js'],
    }));

    expect(manifest.schema).toBe('open-design.design-manifest.v1');
    expect(manifest.entryFile).toBe('index.html');
    expect(manifest.sourceFiles.css).toEqual(['src/app.css']);
    expect(manifest.sourceFiles.scriptsAndComponents).toEqual(['src/app.js']);
    expect(manifest.appModules.join(' ')).toContain('domain-specific in-app modules');
    expect(manifest.osWidgets.join(' ')).toContain('home-screen');
    expect(manifest.responsiveViewports).toContainEqual({
      name: 'tablet-portrait',
      width: 820,
      height: 1180,
      category: 'tablet',
      mustAvoidHorizontalScroll: true,
    });
    expect(manifest.implementationChecklist.join(' ')).toContain('landing pages, in-app modules, and OS widgets');
  });

  it('does not classify plain home.html as a landing page in the manifest', () => {
    const manifest = JSON.parse(buildDesignManifestContent({
      title: 'Product App',
      entryFile: 'home.html',
      files: ['home.html', 'dashboard.html', 'marketing.html'],
    }));

    const screens = new Map(manifest.screens.map((screen: { file: string; role: string }) => [screen.file, screen.role]));
    expect(screens.get('home.html')).not.toBe('landing-page');
    expect(screens.get('marketing.html')).toBe('landing-page');
    expect(screens.get('dashboard.html')).toBe('product-screen');
  });

  it('keeps frame wrapper HTML out of client export manifest screens', () => {
    const manifest = JSON.parse(buildDesignManifestContent({
      title: 'Framed App',
      entryFile: 'index.html',
      files: ['index.html', 'frames/iphone-15-pro.html', 'browser-chrome.html', 'src/app.css'],
    }));

    expect(manifest.sourceFiles.html).toEqual(['browser-chrome.html', 'frames/iphone-15-pro.html', 'index.html']);
    expect(manifest.screens.map((screen: { file: string }) => screen.file)).toEqual(['index.html']);
  });

  it('normalizes a frame-wrapper active file to the implementable screen entry in manifest and handoff', () => {
    const manifest = JSON.parse(buildDesignManifestContent({
      title: 'Framed App',
      entryFile: 'frames/iphone-15-pro.html',
      files: ['index.html', 'landing.html', 'frames/iphone-15-pro.html', 'src/app.css'],
    }));
    const handoff = buildDesignHandoffContent({
      title: 'Framed App',
      entryFile: 'frames/iphone-15-pro.html',
      files: ['index.html', 'landing.html', 'frames/iphone-15-pro.html', 'src/app.css'],
    });

    expect(manifest.entryFile).toBe('index.html');
    expect(manifest.screens.map((screen: { file: string }) => screen.file)).toEqual(['index.html', 'landing.html']);
    expect(handoff).toContain('Primary entry: `index.html`');
    expect(handoff).toContain('Open `index.html` and `DESIGN-MANIFEST.json`');
    expect(handoff).not.toContain('Primary entry: `frames/iphone-15-pro.html`');
  });

  it('keeps phone.html and iphone-upgrade.html as real screens when outside frames/ directory', () => {
    // phone.html as a carrier storefront screen, iphone-upgrade.html as a
    // product surface — neither should be silently dropped from screens just
    // because the filename resembles a device name.
    const manifest = JSON.parse(buildDesignManifestContent({
      title: 'Carrier Storefront',
      entryFile: 'phone.html',
      files: ['phone.html', 'iphone-upgrade.html', 'frames/browser-shell.html', 'src/app.css'],
    }));

    const screenFiles = manifest.screens.map((screen: { file: string }) => screen.file);
    expect(screenFiles).toContain('phone.html');
    expect(screenFiles).toContain('iphone-upgrade.html');
    // frame wrapper inside frames/ is still excluded
    expect(screenFiles).not.toContain('frames/browser-shell.html');
    // both real screens appear in sourceFiles.html
    expect(manifest.sourceFiles.html).toContain('phone.html');
    expect(manifest.sourceFiles.html).toContain('iphone-upgrade.html');
    expect(manifest.sourceFiles.html).toContain('frames/browser-shell.html');
  });
});

describe('exportProjectAsPdf', () => {
  afterEach(() => {
    vi.unstubAllGlobals();
    vi.restoreAllMocks();
  });

  it('uses the daemon desktop PDF export API before falling back to browser print', async () => {
    const fallback = vi.fn();
    vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })));

    const result = await exportProjectAsPdf({
      deck: true,
      fallbackPdf: fallback,
      filePath: 'deck/index.html',
      projectId: 'proj-1',
      title: 'Seed Deck',
    });

    expect(result).toBe('desktop');
    expect(fallback).not.toHaveBeenCalled();
    expect(fetch).toHaveBeenCalledWith('/api/projects/proj-1/export/pdf', {
      body: JSON.stringify({ deck: true, fileName: 'deck/index.html', title: 'Seed Deck' }),
      headers: { 'content-type': 'application/json' },
      method: 'POST',
    });
  });

  it('falls back to browser print when the desktop PDF export API is unavailable', async () => {
    const fallback = vi.fn();
    vi.spyOn(console, 'warn').mockImplementation(() => {});
    vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ error: { message: 'unavailable' } }), { status: 501 })));

    const result = await exportProjectAsPdf({
      deck: false,
      fallbackPdf: fallback,
      filePath: 'index.html',
      projectId: 'proj-1',
      title: 'Landing',
    });

    expect(result).toBe('fallback');
    expect(fallback).toHaveBeenCalledTimes(1);
  });
});

// `exportAsMd` is a pass-through (the file body is the artifact source
// verbatim, only the extension and Content-Type flip). Tests exercise it
// end-to-end by stubbing the few DOM globals `triggerDownload` touches —
// we run under `environment: 'node'`, so `document` and `URL` aren't
// available by default. See issue #279.
describe('exportAsMd', () => {
  let capturedBlob: Blob | undefined;
  let capturedFilename: string | undefined;

  beforeEach(() => {
    capturedBlob = undefined;
    capturedFilename = undefined;
    vi.stubGlobal('URL', {
      createObjectURL: (blob: Blob) => {
        capturedBlob = blob;
        return 'blob:test';
      },
      revokeObjectURL: () => {},
    });
    vi.stubGlobal('document', {
      createElement: () => {
        const anchor = { href: '', click: () => {} } as { href: string; download?: string; click: () => void };
        Object.defineProperty(anchor, 'download', {
          set(value: string) {
            capturedFilename = value;
          },
          get() {
            return capturedFilename ?? '';
          },
        });
        return anchor;
      },
      body: { appendChild: () => {}, removeChild: () => {} },
    });
  });

  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it('downloads the source bytes verbatim under a `.md` extension', async () => {
    const source = '<!doctype html>\n<html lang="en"><body>hi</body></html>\n';

    exportAsMd(source, 'TTC — Seed Round · 2026');

    expect(capturedBlob).toBeDefined();
    expect(capturedBlob!.type).toBe('text/markdown;charset=utf-8');
    // Critical: no transformation, no normalization, no trimming. Whatever
    // the Source view shows is what lands in the .md.
    expect(await capturedBlob!.text()).toBe(source);
    expect(capturedFilename).toBe('TTC-Seed-Round-2026.md');
  });

  it('falls back to "artifact.md" when the title is empty or unsafe', () => {
    exportAsMd('hello', '');
    expect(capturedFilename).toBe('artifact.md');

    exportAsMd('hello', '???');
    expect(capturedFilename).toBe('artifact.md');
  });

  it('keeps multi-byte content (UTF-8) intact end-to-end', async () => {
    const source = '# 中文标题\n\n这是 markdown 文件 — でも本当は HTML 源代码 (مرحبا)。\n';

    exportAsMd(source, 'mixed');

    expect(await capturedBlob!.text()).toBe(source);
  });
});

describe('sandboxed preview Blob exports', () => {
  let capturedBlob: Blob | undefined;
  let openedFeatures: string | undefined;
  let mockWin: { opener: unknown; location: { href: string } };
  let openCalls: string[][];

  beforeEach(() => {
    capturedBlob = undefined;
    openedFeatures = undefined;
    openCalls = [];
    mockWin = { opener: {}, location: { href: '' } };
    vi.stubGlobal('URL', {
      createObjectURL: (blob: Blob) => {
        capturedBlob = blob;
        return 'blob:test';
      },
      revokeObjectURL: vi.fn(),
    });
    vi.stubGlobal('window', {
      open: (_url: string, _target: string, features?: string) => {
        openCalls.push([_url, _target]);
        openedFeatures = features;
        return mockWin;
      },
      addEventListener: () => {},
    });
    vi.stubGlobal('alert', vi.fn());
  });

  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it('wraps generated HTML in an opaque-origin sandbox for new-tab previews', async () => {
    openSandboxedPreviewInNewTab('<script>window.parent.localStorage.clear()</script>', 'Unsafe preview');

    expect(openedFeatures).toBe('noopener,noreferrer');
    expect(capturedBlob).toBeDefined();
    const wrapper = await capturedBlob!.text();
    expect(wrapper).toContain('sandbox="allow-scripts"');
    expect(wrapper).not.toContain('allow-same-origin');
    expect(wrapper).toContain('&lt;script&gt;window.parent.localStorage.clear()&lt;/script&gt;');
    expect(wrapper).not.toContain('<script>window.parent.localStorage.clear()</script>');
  });

  it('passes srcdoc options through the sandboxed new-tab wrapper', async () => {
    openSandboxedPreviewInNewTab('<section class="slide">One</section>', 'Deck preview', {
      deck: true,
      baseHref: '/artifacts/project/assets/',
      initialSlideIndex: 2,
    });

    expect(openedFeatures).toBe('noopener,noreferrer');
    expect(capturedBlob).toBeDefined();
    const wrapper = await capturedBlob!.text();
    expect(wrapper).toContain('sandbox="allow-scripts"');
    expect(wrapper).not.toContain('allow-same-origin');
    expect(wrapper).toContain('&lt;base href=&quot;/artifacts/project/assets/&quot;&gt;');
    expect(wrapper).toContain('od:slide');
  });

  it('can build a print wrapper without granting same-origin access', () => {
    const wrapper = buildSandboxedPreviewDocument('<!doctype html><title>x</title>', 'Print', {
      allowModals: true,
    });

    expect(wrapper).toContain('sandbox="allow-scripts allow-modals"');
    expect(wrapper).not.toContain('allow-same-origin');
  });

  it('uses a sandboxed Blob wrapper with synchronous popup detection for PDF exports', async () => {
    await exportAsPdf('<script>window.parent.document.body.innerHTML="owned"</script>', 'PDF');

    expect(openCalls).toEqual([['', '_blank']]);
    expect(mockWin.opener).toBeNull();
    expect(mockWin.location.href).toBe('blob:test');
    expect(capturedBlob).toBeDefined();
    const wrapper = await capturedBlob!.text();
    expect(wrapper).toContain('sandbox="allow-scripts allow-modals"');
    expect(wrapper).not.toContain('allow-same-origin');
    expect(wrapper).toContain('&lt;script&gt;window.parent.document.body.innerHTML=&quot;owned&quot;&lt;/script&gt;');
    expect(wrapper).not.toContain('<script>window.parent.document.body.innerHTML="owned"</script>');
  });

  it('preserves deck print handling inside sandboxed PDF exports', async () => {
    await exportAsPdf('<section class="slide">One</section>', 'Deck PDF', { deck: true });

    expect(openCalls).toEqual([['', '_blank']]);
    expect(mockWin.opener).toBeNull();
    expect(mockWin.location.href).toBe('blob:test');
    expect(capturedBlob).toBeDefined();
    const wrapper = await capturedBlob!.text();
    expect(wrapper).toContain('sandbox="allow-scripts allow-modals"');
    expect(wrapper).toContain('data-deck-print=&quot;injected&quot;');
    expect(wrapper).toContain('page-break-after: always;');
  });

  it('allows explicit trusted PDF opt-out without changing the secure default', async () => {
    await exportAsPdf('<main>Trusted local document</main>', 'Trusted PDF', {
      sandboxedPreview: false,
    });

    expect(openCalls).toEqual([['', '_blank']]);
    expect(mockWin.opener).toEqual({});
    expect(mockWin.location.href).toBe('blob:test');
    expect(capturedBlob).toBeDefined();
    const doc = await capturedBlob!.text();
    expect(doc).not.toContain('sandbox="allow-scripts allow-modals"');
    expect(doc).toContain('<main>Trusted local document</main>');
  });

  it('shows an alert and revokes the blob URL when the popup is blocked', async () => {
    vi.stubGlobal('window', {
      open: () => null,
      addEventListener: () => {},
    });

    const revokeSpy = URL.revokeObjectURL as ReturnType<typeof vi.fn>;
    revokeSpy.mockClear();

    await exportAsPdf('<p>test</p>', 'Blocked');

    expect(alert).toHaveBeenCalledWith('Popup blocked! Click the popup-blocked icon in your browser address bar (or browser menu), choose "Always allow pop-ups" for this site, then retry Export PDF.');
    expect(revokeSpy).toHaveBeenCalledWith('blob:test');
  });

  it('uses the desktop native print bridge when the host PDF bridge is available', async () => {
    const printPdfMock = vi.fn().mockResolvedValue({ ok: true });
    const restoreHost = installMockOpenDesignHost({
      host: { pdf: { print: printPdfMock } },
    });

    try {
      await exportAsPdf('<script>window.parent.document.body.innerHTML="owned"</script>', 'Desktop PDF');
    } finally {
      restoreHost();
    }

    expect(printPdfMock).toHaveBeenCalledTimes(1);
    expect(openCalls).toEqual([]);

    const htmlArg = printPdfMock.mock.calls[0]![0];
    expect(htmlArg).toContain('sandbox="allow-scripts"');
    expect(htmlArg).not.toContain('allow-modals');
    expect(htmlArg).toContain('&lt;script&gt;window.parent.document.body.innerHTML=&quot;owned&quot;&lt;/script&gt;');
    expect(htmlArg).not.toContain('<script>window.parent.document.body.innerHTML="owned"</script>');
    // Verify the readiness handshake is present — the sandboxed iframe posts
    // 'OD_PRINT_READY' to the parent once fonts and images are loaded.
    expect(htmlArg).toContain('OD_PRINT_READY');
    // Verify the parent-wrapper cache script is present so the handshake is
    // never missed even if 'OD_PRINT_READY' fires before the listener attaches.
    expect(htmlArg).toContain('__odPrintReady');
    // Verify the print script is NOT injected — Electron renders via the
    // native printToPDF path, so a self-printing document would trigger a
    // second print dialog.
    expect(htmlArg).not.toContain('window.print()');
  });

  it('passes deck intent through the desktop native print bridge', async () => {
    const printPdfMock = vi.fn().mockResolvedValue({ ok: true });
    const restoreHost = installMockOpenDesignHost({
      host: { pdf: { print: printPdfMock } },
    });

    try {
      await exportAsPdf('<section class="slide">One</section>', 'Desktop Deck', { deck: true });
    } finally {
      restoreHost();
    }

    expect(printPdfMock).toHaveBeenCalledTimes(1);
    expect(printPdfMock.mock.calls[0]![2]).toEqual({ deck: true });
    expect(printPdfMock.mock.calls[0]![0]).toContain('data-deck-print=&quot;injected&quot;');
  });

  it('injects image-waiting logic into the print-ready handshake for the desktop bridge', async () => {
    const printPdfMock = vi.fn().mockResolvedValue({ ok: true });
    const restoreHost = installMockOpenDesignHost({
      host: { pdf: { print: printPdfMock } },
    });

    // HTML with an intentionally non-loadable image to exercise the
    // incomplete-image detection in the injected handshake.
    const html = '<div><img src="https://example.com/will-not-load.png" alt="test"/></div>';
    try {
      await exportAsPdf(html, 'Image Test');
    } finally {
      restoreHost();
    }

    const htmlArg = printPdfMock.mock.calls[0]![0];
    // In the sandboxed wrapper the srcdoc attribute is HTML-escaped, so the
    // handshake script content is present as unescaped JS fragments.
    expect(htmlArg).toContain('document.images');
    expect(htmlArg).toContain("img.addEventListener('load'");
    expect(htmlArg).toContain("img.addEventListener('error'");
    expect(htmlArg).toContain('img.complete');
    expect(htmlArg).toContain('waitForCssBackgroundImages');
    expect(htmlArg).toContain('window.getComputedStyle');
    expect(htmlArg).toContain('style.backgroundImage');
    expect(htmlArg).toContain('style.borderImageSource');
    expect(htmlArg).toContain('style.listStyleImage');
    expect(htmlArg).toContain('new Image()');
    expect(htmlArg).toContain('requestAnimationFrame');
    // The original font- and load-waiting logic must still be present.
    expect(htmlArg).toContain('document.fonts');
    expect(htmlArg).toContain('OD_PRINT_READY');
    // The handshake posts an object with a per-export nonce to prevent
    // spoofing by untrusted artifact code.
    expect(htmlArg).toContain("type:'OD_PRINT_READY'");
    expect(htmlArg).toContain("nonce:'");
    // The cache script also validates the nonce and event source.
    expect(htmlArg).toContain("e.data.type==='OD_PRINT_READY'");
    expect(htmlArg).toContain("e.data.nonce===");
    expect(htmlArg).toContain('e.source===');
    // The parent cache should still be injected.
    expect(htmlArg).toContain('__odPrintReady');
    // No window.print() since the desktop bridge handles printing natively.
    expect(htmlArg).not.toContain('window.print()');
  });

  it('injects the readiness cache for non-sandboxed desktop exports too', async () => {
    const printPdfMock = vi.fn().mockResolvedValue({ ok: true });
    const restoreHost = installMockOpenDesignHost({
      host: { pdf: { print: printPdfMock } },
    });

    try {
      await exportAsPdf('<main>Trusted local document</main>', 'Trusted', {
        sandboxedPreview: false,
      });
    } finally {
      restoreHost();
    }

    expect(printPdfMock).toHaveBeenCalledTimes(1);
    const htmlArg = printPdfMock.mock.calls[0]![0];
    // No sandbox wrapper — the document is passed through directly.
    expect(htmlArg).not.toContain('sandbox="allow-scripts"');
    expect(htmlArg).toContain('<main>Trusted local document</main>');
    // The readiness handshake must still be injected.
    expect(htmlArg).toContain('OD_PRINT_READY');
    // The cache must be present so waitForPrintReadyHandshake never hangs.
    expect(htmlArg).toContain('__odPrintReady');
    // No window.print() since the desktop bridge handles printing natively.
    expect(htmlArg).not.toContain('window.print()');
  });
});

// ---------------------------------------------------------------------------
// Image screenshot export
// ---------------------------------------------------------------------------

describe('requestPreviewSnapshot', () => {
  let listeners: Map<string, Set<(ev: unknown) => void>>;

  beforeEach(() => {
    listeners = new Map();
    vi.stubGlobal('window', {
      addEventListener: (type: string, fn: (ev: unknown) => void) => {
        if (!listeners.has(type)) listeners.set(type, new Set());
        listeners.get(type)!.add(fn);
      },
      removeEventListener: (type: string, fn: (ev: unknown) => void) => {
        listeners.get(type)?.delete(fn);
      },
      dispatchEvent: (ev: { type: string }) => {
        for (const fn of listeners.get(ev.type) ?? []) fn(ev);
      },
    });
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.restoreAllMocks();
  });

  it('returns null when the iframe has no contentWindow', async () => {
    const iframe = { contentWindow: null } as unknown as HTMLIFrameElement;
    const result = await requestPreviewSnapshot(iframe);
    expect(result).toBeNull();
  });

  it('resolves with snapshot data when the bridge responds', async () => {
    const postMessageMock = vi.fn();
    const contentWindow = { postMessage: postMessageMock };
    const iframe = { contentWindow } as unknown as HTMLIFrameElement;

    const promise = requestPreviewSnapshot(iframe);

    expect(postMessageMock).toHaveBeenCalledOnce();
    const { id } = postMessageMock.mock.calls[0]![0] as { type: string; id: string };

    // Simulate the bridge responding — source must match iframe.contentWindow
    window.dispatchEvent(
      { type: 'message', source: contentWindow, data: { type: 'od:snapshot:result', id, dataUrl: 'data:image/png;base64,abc', w: 100, h: 50 } } as unknown as Event,
    );

    const result = await promise;
    expect(result).toEqual({ dataUrl: 'data:image/png;base64,abc', w: 100, h: 50 });
  });

  it('resolves null when the bridge responds with an error', async () => {
    const postMessageMock = vi.fn();
    const contentWindow = { postMessage: postMessageMock };
    const iframe = { contentWindow } as unknown as HTMLIFrameElement;

    const promise = requestPreviewSnapshot(iframe);
    const { id } = postMessageMock.mock.calls[0]![0] as { type: string; id: string };

    window.dispatchEvent(
      { type: 'message', source: contentWindow, data: { type: 'od:snapshot:result', id, error: 'snapshot image failed' } } as unknown as Event,
    );

    const result = await promise;
    expect(result).toBeNull();
  });

  it('resolves null on timeout', async () => {
    vi.useFakeTimers();
    const iframe = {
      contentWindow: { postMessage: vi.fn() },
    } as unknown as HTMLIFrameElement;

    const promise = requestPreviewSnapshot(iframe, 100);
    vi.advanceTimersByTime(150);

    const result = await promise;
    expect(result).toBeNull();
    vi.useRealTimers();
  });

  it('ignores messages with a mismatched id', async () => {
    vi.useFakeTimers();
    const postMessageMock = vi.fn();
    const contentWindow = { postMessage: postMessageMock };
    const iframe = { contentWindow } as unknown as HTMLIFrameElement;

    const promise = requestPreviewSnapshot(iframe, 100);

    // Correct source but wrong id — should be ignored
    window.dispatchEvent(
      { type: 'message', source: contentWindow, data: { type: 'od:snapshot:result', id: 'wrong-id', dataUrl: 'data:image/png;base64,abc', w: 100, h: 50 } } as unknown as Event,
    );

    vi.advanceTimersByTime(150);
    const result = await promise;
    expect(result).toBeNull();
    vi.useRealTimers();
  });

  it('ignores messages from a different source window', async () => {
    vi.useFakeTimers();
    const postMessageMock = vi.fn();
    const contentWindow = { postMessage: postMessageMock };
    const iframe = { contentWindow } as unknown as HTMLIFrameElement;

    const promise = requestPreviewSnapshot(iframe, 100);
    const { id } = postMessageMock.mock.calls[0]![0] as { type: string; id: string };

    // Correct id but wrong source — should be ignored
    window.dispatchEvent(
      { type: 'message', source: { other: true }, data: { type: 'od:snapshot:result', id, dataUrl: 'data:image/png;base64,abc', w: 100, h: 50 } } as unknown as Event,
    );

    vi.advanceTimersByTime(150);
    const result = await promise;
    expect(result).toBeNull();
    vi.useRealTimers();
  });
});

describe('exportAsImage', () => {
  let clickMock: ReturnType<typeof vi.fn>;
  let createObjectURLMock: ReturnType<typeof vi.fn>;
  let anchors: Array<{ href: string; download: string; click: ReturnType<typeof vi.fn> }>;

  beforeEach(() => {
    clickMock = vi.fn();
    createObjectURLMock = vi.fn(() => 'blob:mock-url');
    anchors = [];
    vi.stubGlobal('URL', { createObjectURL: createObjectURLMock, revokeObjectURL: vi.fn() });
    vi.stubGlobal('document', {
      createElement: () => {
        const el = { href: '', download: '', click: clickMock };
        anchors.push(el);
        return el;
      },
      body: {
        appendChild: vi.fn(),
        removeChild: vi.fn(),
      },
    });
    // triggerDownload calls setTimeout for deferred revoke
    vi.stubGlobal('setTimeout', (fn: () => void) => fn());
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.restoreAllMocks();
  });

  it('triggers a download with a .png filename', () => {
    const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
    exportAsImage(dataUrl, 'My Design');

    expect(clickMock).toHaveBeenCalledOnce();
    expect(anchors).toHaveLength(1);
    expect(anchors[0]!.download).toBe('My-Design.png');
  });

  it('sanitizes the title into a safe filename', () => {
    exportAsImage('data:image/png;base64,AA==', 'Hello <World> / Test!');

    expect(anchors[0]!.download).toBe('Hello-World-Test.png');
  });

  it('does not download an empty image snapshot', () => {
    expect(() => exportAsImage('data:image/png;base64,', 'Empty')).toThrow('Image snapshot is empty');

    expect(clickMock).not.toHaveBeenCalled();
    expect(anchors).toHaveLength(0);
  });

  it('downloads a validated image data URL without creating a blob URL', () => {
    const dataUrl = 'data:image/png;base64,AA==';

    downloadImageDataUrl(dataUrl, 'workspace.png');

    expect(clickMock).toHaveBeenCalledOnce();
    expect(createObjectURLMock).not.toHaveBeenCalled();
    expect(anchors[0]!.href).toBe(dataUrl);
    expect(anchors[0]!.download).toBe('workspace.png');
  });

  it('does not download an empty image data URL', () => {
    expect(() => downloadImageDataUrl('data:image/png;base64,', 'workspace.png')).toThrow('Image snapshot is empty');

    expect(clickMock).not.toHaveBeenCalled();
    expect(anchors).toHaveLength(0);
  });

  it('falls back to download when the native save picker is blocked', async () => {
    const showSaveFilePicker = vi.fn().mockRejectedValue(
      new DOMException('Must be handling a user gesture to show a file picker.', 'SecurityError'),
    );
    vi.stubGlobal('window', { showSaveFilePicker });

    const target = await prepareImageExportTarget('My Design', 'jpeg');

    expect(showSaveFilePicker).toHaveBeenCalledOnce();
    expect(target?.method).toBe('download');
    expect(target?.filename).toBe('My-Design.jpg');

    await target?.save(new Blob(['jpeg'], { type: 'image/jpeg' }));

    expect(clickMock).toHaveBeenCalledOnce();
    expect(anchors[0]!.download).toBe('My-Design.jpg');
  });

  it('falls back to download when the native save picker reports a cross-realm SecurityError', async () => {
    const securityError = Object.assign(new Error('Must be handling a user gesture to show a file picker.'), {
      name: 'SecurityError',
    });
    const showSaveFilePicker = vi.fn().mockRejectedValue(securityError);
    vi.stubGlobal('window', { showSaveFilePicker });

    const target = await prepareImageExportTarget('My Design', 'webp');

    expect(showSaveFilePicker).toHaveBeenCalledOnce();
    expect(target?.method).toBe('download');
    expect(target?.filename).toBe('My-Design.webp');
  });

  it('can skip the native save picker to avoid pre-creating empty files', async () => {
    const showSaveFilePicker = vi.fn();
    vi.stubGlobal('window', { showSaveFilePicker });

    const target = await prepareImageExportTarget('My Design', 'png', { useNativePicker: false });

    expect(showSaveFilePicker).not.toHaveBeenCalled();
    expect(target?.method).toBe('download');
    expect(target?.filename).toBe('My-Design.png');
  });
});
