// @vitest-environment node

import { describe, expect, it, vi } from 'vitest';
import { JSDOM } from 'jsdom';
import { buildSrcdoc } from '../../src/runtime/srcdoc';

// Behavioral coverage for nexu-io/open-design#890. The Examples / Tweaks
// preview iframe runs the bridge script generated by `buildSrcdoc`. When
// the agent emits an artifact without `data-od-id` / `data-screen-label`
// (e.g. a freeform PRD → HTML pass through a Claude-Code-compatible CLI
// without a skill), the bridge must:
//
//   1. Still post `od:comment-targets` with `targets: []` so the host
//      can detect the empty-annotation state and surface a clearer
//      hint than "Click any element with `data-od-id` …" — without
//      this, FileViewer's `liveCommentTargets` map never updates and
//      the hint banner sticks at its instructive-default state even
//      though there's nothing to click.
//   2. In Comment picker mode, fall back to meaningful DOM selectors
//      for unannotated surfaces so imported or partially annotated HTML
//      can still be reviewed. Inspect mode still needs a real annotated
//      selector for live style persistence.
//
// The host-side hint switch lives in `apps/web/src/components/FileViewer.tsx`
// (search for `inspect-empty-hint-no-targets`); these tests pin the
// signal it depends on.

function extractBridgeScript(srcdoc: string): string {
  // The bridge is wrapped in `<script data-od-selection-bridge>(function(){…})()</script>`.
  const match = srcdoc.match(
    /<script data-od-selection-bridge>([\s\S]*?)<\/script>/,
  );
  if (!match || !match[1]) {
    throw new Error('selection bridge script not found in srcdoc');
  }
  return match[1];
}

function extractSelectionBridgeStyle(srcdoc: string): string {
  const match = srcdoc.match(
    /<style data-od-selection-bridge-style>[\s\S]*?<\/style>/,
  );
  return match?.[0] ?? '';
}

function markVisible(win: { document: Document }, selector: string): void {
  const el = win.document.querySelector(selector);
  if (!el) throw new Error(`selector not found: ${selector}`);
  Object.defineProperty(el, 'getBoundingClientRect', {
    configurable: true,
    value: () => ({
      x: 10,
      y: 20,
      width: 120,
      height: 48,
      top: 20,
      right: 130,
      bottom: 68,
      left: 10,
      toJSON: () => ({}),
    }),
  });
}

function setupBridgeDom(
  bodyHtml: string,
  mode: 'inspect' | 'comment',
  visibleSelectors: string[] = [],
) {
  const srcdoc = buildSrcdoc(`<!doctype html><html><body>${bodyHtml}</body></html>`, {
    inspectBridge: mode === 'inspect',
    commentBridge: mode === 'comment',
  });
  const script = extractBridgeScript(srcdoc);
  const bridgeStyle = extractSelectionBridgeStyle(srcdoc);

  // Build a fresh JSDOM that mirrors the iframe runtime: `window.parent`
  // is what the bridge calls into, and we spy on its postMessage to
  // capture the messages it would emit to the host.
  const dom = new JSDOM(
    `<!doctype html><html><head>${bridgeStyle}</head><body>${bodyHtml}</body></html>`,
    {
      runScripts: 'outside-only',
      pretendToBeVisual: true,
    },
  );
  const win = dom.window;
  const parentPostMessage = vi.fn();
  // jsdom defaults `window.parent` to `window` itself for top-level
  // documents; replace it with a stub that has a spied postMessage so
  // we can observe what the bridge would send to the embedding host.
  Object.defineProperty(win, 'parent', {
    configurable: true,
    value: { postMessage: parentPostMessage },
  });
  visibleSelectors.forEach((selector) => markVisible(win, selector));

  // Run the bridge IIFE inside the jsdom window so its `document` /
  // `window` refer to our DOM. We don't use `runScripts: 'dangerously'`
  // — the IIFE is our trusted source, evaluated by `Function` in the
  // jsdom realm.
  const evaluate = new win.Function(script);
  evaluate.call(win);

  return { dom, win, parentPostMessage };
}

describe('selection bridge — empty annotation surface (#890)', () => {
  it('posts od:comment-targets with an empty list when the artifact has no annotated elements', async () => {
    const { win, parentPostMessage } = setupBridgeDom(
      // PRD-style mockup: real DOM, zero `data-od-id` / `data-screen-label`.
      '<header><h1>Acme PRD</h1></header><main><section><p>Goals</p></section></main>',
      'inspect',
    );

    // The bridge schedules the initial postTargets via setTimeout(0)
    // after enabling the mode. Drain microtasks + the timer so the
    // message lands before we assert.
    await new Promise<void>((resolve) => win.setTimeout(resolve, 10));

    const targetMessages = parentPostMessage.mock.calls
      .map((call) => call[0])
      .filter((message) => message?.type === 'od:comment-targets');

    expect(targetMessages.length).toBeGreaterThan(0);
    // Every targets-broadcast must be an empty list — there's nothing
    // annotated to enumerate. If a future change starts inventing
    // synthetic ids in `allTargets()`, this assertion fires before
    // the host's empty-state hint silently disappears.
    for (const message of targetMessages) {
      expect(message.targets).toEqual([]);
    }
  });

  it('does not post od:comment-target when the user clicks an unannotated element', async () => {
    const { win, parentPostMessage } = setupBridgeDom(
      '<header><h1 id="title">Acme PRD</h1></header>',
      'inspect',
    );

    // Wait for bridge boot.
    await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
    parentPostMessage.mockClear();

    // Click directly on the <h1> — it has no `data-od-id` / `data-screen-label`,
    // and no ancestor does either. The bridge's `closestTarget()` walks up
    // to <html> and returns null; the click handler must bail before
    // emitting a comment-target message.
    const target = win.document.getElementById('title');
    expect(target).not.toBeNull();
    target!.dispatchEvent(
      new win.MouseEvent('click', { bubbles: true, cancelable: true }),
    );

    const clickMessages = parentPostMessage.mock.calls
      .map((call) => call[0])
      .filter((message) => message?.type === 'od:comment-target');
    expect(clickMessages).toEqual([]);
  });

  it('still posts od:comment-target when the click traverses up to an annotated ancestor', async () => {
    // Sanity check / contract pin: the no-op behavior above is specific
    // to the no-annotation case. When ANY ancestor carries `data-od-id`,
    // the click must still resolve to that ancestor — this is the
    // happy path the gallery has shipped since the bridge landed.
    const { win, parentPostMessage } = setupBridgeDom(
      '<main data-od-id="hero"><h1 id="title">Hero</h1></main>',
      'inspect',
      ['[data-od-id="hero"]'],
    );

    await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
    parentPostMessage.mockClear();

    win.document.getElementById('title')!.dispatchEvent(
      new win.MouseEvent('click', { bubbles: true, cancelable: true }),
    );

    const clickMessages = parentPostMessage.mock.calls
      .map((call) => call[0])
      .filter((message) => message?.type === 'od:comment-target');
    expect(clickMessages).toHaveLength(1);
    expect(clickMessages[0].elementId).toBe('hero');
    expect(clickMessages[0].clickedDescendant).toEqual({
      label: 'h1',
      text: 'Hero',
    });
  });

  it('does not add clickedDescendant when the annotated target itself is clicked', async () => {
    const { win, parentPostMessage } = setupBridgeDom(
      '<main data-od-id="hero">Hero</main>',
      'inspect',
      ['[data-od-id="hero"]'],
    );

    await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
    parentPostMessage.mockClear();

    win.document.querySelector('[data-od-id="hero"]')!.dispatchEvent(
      new win.MouseEvent('click', { bubbles: true, cancelable: true }),
    );

    const clickMessages = parentPostMessage.mock.calls
      .map((call) => call[0])
      .filter((message) => message?.type === 'od:comment-target');
    expect(clickMessages).toHaveLength(1);
    expect(clickMessages[0].elementId).toBe('hero');
    expect(clickMessages[0].clickedDescendant).toBeUndefined();
  });

  it('refreshes the active comment target when its text node mutates after picking it', async () => {
    const { win, parentPostMessage } = setupBridgeDom(
      '<main data-od-id="hero">Draft copy</main>',
      'comment',
      ['[data-od-id="hero"]'],
    );

    await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
    parentPostMessage.mockClear();

    const target = win.document.querySelector('[data-od-id="hero"]');
    expect(target).not.toBeNull();
    target!.dispatchEvent(
      new win.MouseEvent('click', { bubbles: true, cancelable: true }),
    );

    const clickMessages = parentPostMessage.mock.calls
      .map((call) => call[0])
      .filter((message) => message?.type === 'od:comment-target');
    expect(clickMessages).toHaveLength(1);
    expect(clickMessages[0].text).toBe('Draft copy');

    parentPostMessage.mockClear();
    target!.firstChild!.textContent = 'Updated copy';
    await new Promise<void>((resolve) => win.setTimeout(resolve, 20));

    const updateMessages = parentPostMessage.mock.calls
      .map((call) => call[0])
      .filter((message) => message?.type === 'od:comment-active-target-update');
    expect(updateMessages).toHaveLength(1);
    expect(updateMessages[0].elementId).toBe('hero');
    expect(updateMessages[0].text).toBe('Updated copy');
  });

  it('does not invent fallback targets in Inspect mode for unannotated elements', async () => {
    const { win, parentPostMessage } = setupBridgeDom(
      '<main><button id="cta">Launch</button></main>',
      'inspect',
      ['#cta'],
    );

    await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
    parentPostMessage.mockClear();

    win.document.getElementById('cta')!.dispatchEvent(
      new win.MouseEvent('click', { bubbles: true, cancelable: true }),
    );

    const clickMessages = parentPostMessage.mock.calls
      .map((call) => call[0])
      .filter((message) => message?.type === 'od:comment-target');
    expect(clickMessages).toEqual([]);
  });

  it('uses a DOM selector fallback for Picker mode when elements are unannotated', async () => {
    const { win, parentPostMessage } = setupBridgeDom(
      '<main><button id="cta">Launch</button></main>',
      'comment',
      ['#cta'],
    );

    await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
    parentPostMessage.mockClear();

    win.document.getElementById('cta')!.dispatchEvent(
      new win.MouseEvent('click', { bubbles: true, cancelable: true }),
    );

    const clickMessages = parentPostMessage.mock.calls
      .map((call) => call[0])
      .filter((message) => message?.type === 'od:comment-target');
    expect(clickMessages).toHaveLength(1);
    expect(clickMessages[0].elementId).toBe('dom:body > main:nth-of-type(1) > button:nth-of-type(1)');
    expect(clickMessages[0].selector).toBe('body > main:nth-of-type(1) > button:nth-of-type(1)');
    expect(clickMessages[0].text).toBe('Launch');
  });

  it('uses Picker DOM fallback for meaningful unannotated elements on mixed pages', async () => {
    const { win, parentPostMessage } = setupBridgeDom(
      '<main><section data-od-id="hero">Hero</section><button id="cta">Launch</button></main>',
      'comment',
      ['#cta'],
    );

    await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
    parentPostMessage.mockClear();

    win.document.getElementById('cta')!.dispatchEvent(
      new win.MouseEvent('click', { bubbles: true, cancelable: true }),
    );

    const clickMessages = parentPostMessage.mock.calls
      .map((call) => call[0])
      .filter((message) => message?.type === 'od:comment-target');
    expect(clickMessages).toHaveLength(1);
    expect(clickMessages[0]).toMatchObject({
      elementId: 'dom:body > main:nth-of-type(1) > button:nth-of-type(1)',
      selector: 'body > main:nth-of-type(1) > button:nth-of-type(1)',
      text: 'Launch',
    });
  });

  it('uses leaf-first DOM fallback in Comment mode instead of an annotated section ancestor', async () => {
    const { win, parentPostMessage } = setupBridgeDom(
      '<section data-screen-label="01 Hero"><h1 id="headline"><span>ERP</span> and CRM delivery</h1><p>Subhead</p></section>',
      'comment',
      ['#headline'],
    );

    await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
    parentPostMessage.mockClear();

    win.document.getElementById('headline')!.dispatchEvent(
      new win.MouseEvent('click', { bubbles: true, cancelable: true }),
    );

    const clickMessages = parentPostMessage.mock.calls
      .map((call) => call[0])
      .filter((message) => message?.type === 'od:comment-target');
    expect(clickMessages).toHaveLength(1);
    expect(clickMessages[0]).toMatchObject({
      elementId: 'dom:body > section:nth-of-type(1) > h1:nth-of-type(1)',
      selector: 'body > section:nth-of-type(1) > h1:nth-of-type(1)',
      label: 'h1',
      text: 'ERP and CRM delivery',
      position: {
        x: 10,
        y: 20,
        width: 120,
        height: 48,
      },
    });
  });

  it('does not broadcast the generated React root as a page-sized Comment target', async () => {
    const { win, parentPostMessage } = setupBridgeDom(
      '<div id="root" data-od-id="path-0"><header><a>Brand</a></header><main><h1 id="headline">Hero</h1></main><footer>Footer</footer></div>',
      'comment',
      ['#root', '#headline'],
    );

    await new Promise<void>((resolve) => win.setTimeout(resolve, 10));

    const targetMessages = parentPostMessage.mock.calls
      .map((call) => call[0])
      .filter((message) => message?.type === 'od:comment-targets');
    expect(targetMessages.length).toBeGreaterThan(0);
    const last = targetMessages.at(-1);
    expect(last.targets).not.toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          elementId: 'path-0',
        }),
      ]),
    );
  });

  it('broadcasts DOM fallback targets in comment mode so Pods can hit-test unannotated pages', async () => {
    const { win, parentPostMessage } = setupBridgeDom(
      '<main><section id="card"><h2>Card</h2></section></main>',
      'comment',
      ['#card'],
    );

    await new Promise<void>((resolve) => win.setTimeout(resolve, 10));

    const targetMessages = parentPostMessage.mock.calls
      .map((call) => call[0])
      .filter((message) => message?.type === 'od:comment-targets');
    expect(targetMessages.length).toBeGreaterThan(0);
    const last = targetMessages.at(-1);
    expect(last.targets).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          elementId: 'dom:body > main:nth-of-type(1) > section:nth-of-type(1)',
          selector: 'body > main:nth-of-type(1) > section:nth-of-type(1)',
          text: 'Card',
        }),
      ]),
    );
  });

  it('applies pointer-events none to body iframes while Comment picker is active', async () => {
    const { win } = setupBridgeDom(
      '<article data-od-id="card"><div>Tablet edition</div><iframe id="f" src="about:blank"></iframe></article>',
      'comment',
      ['#f'],
    );
    await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
    const iframe = win.document.getElementById('f');
    expect(iframe).not.toBeNull();
    expect(win.getComputedStyle(iframe!).pointerEvents).toBe('none');
  });

  it('applies pointer-events none to body iframes while Inspect picker is active', async () => {
    const { win } = setupBridgeDom(
      '<article data-od-id="card"><iframe id="f" src="about:blank"></iframe></article>',
      'inspect',
      ['#f'],
    );
    await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
    expect(win.getComputedStyle(win.document.getElementById('f')!).pointerEvents).toBe('none');
  });

  it('restores iframe pointer events after Comment mode is disabled', async () => {
    const { win } = setupBridgeDom(
      '<article data-od-id="card"><iframe id="f" src="about:blank"></iframe></article>',
      'comment',
      ['#f'],
    );
    await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
    const iframe = win.document.getElementById('f')!;
    expect(win.getComputedStyle(iframe).pointerEvents).toBe('none');

    win.dispatchEvent(
      new win.MessageEvent('message', {
        data: { type: 'od:comment-mode', enabled: false, mode: 'picker' },
      }),
    );
    expect(win.getComputedStyle(iframe).pointerEvents).toBe('auto');
  });

  it('posts od:comment-target for the annotated card when the device-frame iframe is clicked', async () => {
    const { win, parentPostMessage } = setupBridgeDom(
      '<article data-od-id="tablet-card" class="frame-card"><div class="meta">Tablet edition</div><iframe id="f" class="tablet-frame" title="Tablet edition" src="about:blank"></iframe></article>',
      'comment',
      ['#f', '.frame-card'],
    );
    await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
    parentPostMessage.mockClear();

    win.document.getElementById('f')!.dispatchEvent(
      new win.MouseEvent('click', { bubbles: true, cancelable: true }),
    );

    const clickMessages = parentPostMessage.mock.calls
      .map((call) => call[0])
      .filter((message) => message?.type === 'od:comment-target');
    expect(clickMessages).toHaveLength(1);
    expect(clickMessages[0].elementId).toBe('tablet-card');
  });
});
