import { describe, expect, it, vi } from 'vitest';
import { CliError } from '../errors.js';
import { BasePage } from './base-page.js';
import { TargetError } from './target-errors.js';
import type { BrowserEvaluateFunction, ScreenshotOptions } from '../types.js';

class TestPage extends BasePage {
  result: unknown;
  args: Record<string, unknown> | undefined;

  async goto(): Promise<void> {}
  async evaluate<T = unknown>(_js: string): Promise<T>;
  async evaluate<Args extends unknown[], T>(_fn: BrowserEvaluateFunction<Args, T>, ..._args: Args): Promise<Awaited<T>>;
  async evaluate(): Promise<unknown> { return null; }
  override async evaluateWithArgs(_js: string, args: Record<string, unknown>): Promise<unknown> {
    this.args = args;
    return this.result;
  }
  async getCookies(): Promise<[]> { return []; }
  async screenshot(): Promise<string> { return ''; }
  async tabs(): Promise<unknown[]> { return []; }
  async selectTab(): Promise<void> {}
}

class ActionPage extends BasePage {
  results: unknown[] = [];
  withArgsResults: unknown[] = [];
  scripts: string[] = [];
  withArgs: Record<string, unknown>[] = [];
  screenshotCalls: ScreenshotOptions[] = [];
  nativeType?: (text: string) => Promise<void>;
  insertText?: (text: string) => Promise<void>;
  nativeKeyPress?: (key: string, modifiers?: string[]) => Promise<void>;
  nativeClick?: (x: number, y: number) => Promise<void>;
  setFileInput?: (files: string[], selector?: string) => Promise<void>;
  cdp?: (method: string, params?: Record<string, unknown>) => Promise<unknown>;

  async goto(): Promise<void> {}
  async evaluate<T = unknown>(js: string): Promise<T>;
  async evaluate<Args extends unknown[], T>(fn: BrowserEvaluateFunction<Args, T>, ...args: Args): Promise<Awaited<T>>;
  async evaluate(input: string | BrowserEvaluateFunction<unknown[], unknown>): Promise<unknown> {
    this.scripts.push(typeof input === 'string' ? input : input.toString());
    return this.results.shift() ?? null;
  }
  override async evaluateWithArgs(js: string, args: Record<string, unknown>): Promise<unknown> {
    this.scripts.push(js);
    this.withArgs.push(args);
    return this.withArgsResults.shift() ?? null;
  }
  async getCookies(): Promise<[]> { return []; }
  async screenshot(options: ScreenshotOptions = {}): Promise<string> {
    this.screenshotCalls.push(options);
    return 'shot';
  }
  async tabs(): Promise<unknown[]> { return []; }
  async selectTab(): Promise<void> {}
}

const resolveOk = { ok: true, matches_n: 1, match_level: 'exact' };

describe('BasePage.fetchJson', () => {
  it('passes a narrow browser-context JSON request and parses the response in Node', async () => {
    const page = new TestPage();
    page.result = {
      ok: true,
      status: 200,
      url: 'https://api.example.com/items',
      contentType: 'application/json',
      text: '{"items":[1]}',
    };

    await expect(page.fetchJson('https://api.example.com/items', {
      method: 'POST',
      headers: { 'X-Test': '1' },
      body: { q: 'opencli' },
      timeoutMs: 1234,
    })).resolves.toEqual({ items: [1] });

    expect(page.args).toEqual({
      request: {
        url: 'https://api.example.com/items',
        method: 'POST',
        headers: { 'X-Test': '1' },
        body: { q: 'opencli' },
        hasBody: true,
        timeoutMs: 1234,
      },
    });
  });

  it('throws a CliError for non-JSON responses', async () => {
    const page = new TestPage();
    page.result = {
      ok: true,
      status: 200,
      url: 'https://api.example.com/items',
      contentType: 'text/html',
      text: '<html>blocked</html>',
    };

    const err = await page.fetchJson('https://api.example.com/items').catch((error: unknown) => error);
    expect(err).toBeInstanceOf(CliError);
    expect((err as CliError).code).toBe('FETCH_ERROR');
    expect((err as CliError).message).toContain('Expected JSON');
    expect((err as CliError).hint).toContain('blocked');
  });

  it('throws a CliError for browser fetch transport errors', async () => {
    const page = new TestPage();
    page.result = {
      ok: false,
      status: 0,
      url: 'https://api.example.com/items',
      text: '',
      error: 'The operation was aborted.',
    };

    await expect(page.fetchJson('https://api.example.com/items')).rejects.toMatchObject({
      code: 'FETCH_ERROR',
      message: expect.stringContaining('The operation was aborted.'),
    });
  });
});

describe('BasePage annotatedScreenshot', () => {
  it('refreshes DOM refs, captures with a temporary visual overlay, and cleans up', async () => {
    const page = new ActionPage();
    page.results = [
      'snapshot',
      '["hash"]',
      { annotated: 1, truncated: false },
      true,
    ];

    await expect(page.annotatedScreenshot({ path: '/tmp/opencli.png', annotate: true })).resolves.toBe('shot');

    expect(page.scripts[0]).toContain('const VIEWPORT_EXPAND = 0');
    expect(page.scripts[2]).toContain('__opencli_visual_ref_overlay');
    expect(page.scripts[2]).toContain('[data-opencli-ref]');
    expect(page.scripts[3]).toContain('__opencli_visual_ref_overlay');
    expect(page.screenshotCalls).toEqual([{ path: '/tmp/opencli.png', annotate: false }]);
  });
});

describe('BasePage native input routing', () => {
  it('types rich-editor text via native Input.insertText when available', async () => {
    const page = new ActionPage();
    page.nativeType = vi.fn().mockResolvedValue(undefined);
    page.results = [resolveOk, { ok: true, mode: 'contenteditable' }];

    await expect(page.typeText('#editor', 'hello')).resolves.toEqual({ matches_n: 1, match_level: 'exact' });

    expect(page.nativeType).toHaveBeenCalledWith('hello');
    expect(page.scripts).toHaveLength(2);
    expect(page.scripts[1]).toContain('nearestContentEditableHost');
    expect(page.scripts.join('\n')).not.toContain("return 'typed'");
  });

  it('uses CDP DOM focus and scroll before native text insertion when available', async () => {
    const page = new ActionPage();
    page.nativeType = vi.fn().mockResolvedValue(undefined);
    page.cdp = vi.fn()
      .mockResolvedValueOnce({})
      .mockResolvedValueOnce({ root: { nodeId: 1 } })
      .mockResolvedValueOnce({ nodeId: 7 })
      .mockResolvedValueOnce({})
      .mockResolvedValueOnce({})
      .mockResolvedValueOnce({ root: { nodeId: 1 } })
      .mockResolvedValueOnce({ nodeId: 7 })
      .mockResolvedValueOnce({});
    page.results = [resolveOk, { ok: true, mode: 'input' }];
    page.withArgsResults = [{ ok: true }, undefined, { ok: true }, undefined];

    await page.typeText('#q', 'hello');

    expect(page.cdp).toHaveBeenCalledWith('DOM.scrollIntoViewIfNeeded', { nodeId: 7 });
    expect(page.cdp).toHaveBeenCalledWith('DOM.focus', { nodeId: 7 });
    expect(page.nativeType).toHaveBeenCalledWith('hello');
    expect(page.scripts.at(-1)).toContain('if (false) el.scrollIntoView');
    expect(page.scripts.at(-1)).toContain('if (false) {');
  });

  it('keeps the DOM setter fallback when native text insertion is unavailable', async () => {
    const page = new ActionPage();
    page.results = [resolveOk, 'typed'];

    await page.typeText('#q', 'hello');

    expect(page.scripts).toHaveLength(2);
    expect(page.scripts[1]).toContain('document.execCommand');
    expect(page.scripts[1]).toContain("return 'typed'");
  });

  it('falls back to DOM typing if native text insertion fails', async () => {
    const page = new ActionPage();
    page.nativeType = vi.fn().mockRejectedValue(new Error('native failed'));
    page.results = [resolveOk, { ok: true, mode: 'input' }, 'typed'];

    await page.typeText('#q', 'hello');

    expect(page.nativeType).toHaveBeenCalledWith('hello');
    expect(page.scripts).toHaveLength(3);
    expect(page.scripts[2]).toContain("return 'typed'");
  });

  it('fills text through the native input path and verifies the exact value', async () => {
    const page = new ActionPage();
    page.nativeType = vi.fn().mockResolvedValue(undefined);
    page.results = [
      resolveOk,
      { ok: true, mode: 'textarea' },
      { ok: true, actual: 'line1\\n/ / raw', expected: 'line1\\n/ / raw', length: 14, mode: 'textarea' },
    ];

    await expect(page.fillText('#message', 'line1\\n/ / raw')).resolves.toEqual({
      filled: true,
      verified: true,
      expected: 'line1\\n/ / raw',
      actual: 'line1\\n/ / raw',
      length: 14,
      matches_n: 1,
      match_level: 'exact',
      mode: 'textarea',
    });

    expect(page.nativeType).toHaveBeenCalledWith('line1\\n/ / raw');
    expect(page.scripts).toHaveLength(3);
    expect(page.scripts[2]).toContain('actual ===');
  });

  it('falls back to the DOM setter when native fill insertion is unavailable', async () => {
    const page = new ActionPage();
    page.results = [
      resolveOk,
      { ok: true, mode: 'input' },
      'typed',
      { ok: true, actual: 'hello', expected: 'hello', length: 5, mode: 'input' },
    ];

    await expect(page.fillText('#q', 'hello')).resolves.toEqual(expect.objectContaining({
      filled: true,
      verified: true,
      actual: 'hello',
      mode: 'input',
    }));

    expect(page.scripts).toHaveLength(4);
    expect(page.scripts[2]).toContain("return 'typed'");
  });

  it('falls back to DOM fill if native insertion does not verify', async () => {
    const page = new ActionPage();
    page.nativeType = vi.fn().mockResolvedValue(undefined);
    page.results = [
      resolveOk,
      { ok: true, mode: 'input' },
      { ok: false, actual: '', expected: 'hello', length: 0, mode: 'input' },
      'typed',
      { ok: true, actual: 'hello', expected: 'hello', length: 5, mode: 'input' },
    ];

    await expect(page.fillText('#q', 'hello')).resolves.toEqual(expect.objectContaining({
      filled: true,
      verified: true,
      actual: 'hello',
    }));

    expect(page.nativeType).toHaveBeenCalledWith('hello');
    expect(page.scripts).toHaveLength(5);
    expect(page.scripts[3]).toContain("return 'typed'");
  });

  it('throws a structured not_editable error for non-fillable targets', async () => {
    const page = new ActionPage();
    page.results = [resolveOk, { ok: false, reason: 'not_editable', tag: 'button' }];

    const err = await page.fillText('button', 'hello').catch((error: unknown) => error);

    expect(err).toBeInstanceOf(TargetError);
    expect((err as TargetError).code).toBe('not_editable');
  });

  it('uses CDP DOM scrollIntoViewIfNeeded before measuring rect for click', async () => {
    const page = new ActionPage();
    page.nativeClick = vi.fn().mockResolvedValue(undefined);
    page.cdp = vi.fn()
      .mockResolvedValueOnce({})
      .mockResolvedValueOnce({ root: { nodeId: 1 } })
      .mockResolvedValueOnce({ nodeId: 9 })
      .mockResolvedValueOnce({});
    page.results = [resolveOk, { x: 50, y: 100, w: 200, h: 32, visible: true }];
    page.withArgsResults = [{ ok: true, multiple: false, accept: 'application/pdf' }, undefined];

    await page.click('#save');

    expect(page.cdp).toHaveBeenCalledWith('DOM.scrollIntoViewIfNeeded', { nodeId: 9 });
    // After CDP scroll, boundingRectResolvedJs runs with skipScroll=true.
    expect(page.scripts.at(-1)).toContain('if (false) el.scrollIntoView');
  });

  it('clicks via CDP Input.dispatchMouseEvent when rect is visible', async () => {
    const page = new ActionPage();
    page.nativeClick = vi.fn().mockResolvedValue(undefined);
    page.results = [resolveOk, { x: 50, y: 100, w: 200, h: 32, visible: true }];

    await page.click('#category');

    expect(page.nativeClick).toHaveBeenCalledWith(50, 100);
    expect(page.nativeClick).toHaveBeenCalledTimes(1);
    expect(page.scripts).toHaveLength(2);
    expect(page.scripts[1]).toContain('getBoundingClientRect');
    expect(page.scripts.join('\n')).not.toContain('el.click()');
  });

  it('clicks AX snapshot refs through backend node coordinates without DOM resolver', async () => {
    const page = new ActionPage();
    page.nativeClick = vi.fn().mockResolvedValue(undefined);
    page.cdp = vi.fn(async (method: string) => {
      if (method === 'Accessibility.getFullAXTree') {
        return {
          nodes: [
            { nodeId: '1', role: { value: 'RootWebArea' }, name: { value: 'Demo' }, childIds: ['2'] },
            { nodeId: '2', role: { value: 'button' }, name: { value: 'Submit' }, backendDOMNodeId: 10 },
          ],
        };
      }
      if (method === 'Page.getFrameTree') {
        return {
          frameTree: { frame: { id: 'root', url: 'https://app.example/' } },
        };
      }
      if (method === 'DOM.getBoxModel') {
        return { model: { content: [10, 20, 50, 20, 50, 40, 10, 40] } };
      }
      return {};
    });

    await expect(page.snapshot({ source: 'ax' })).resolves.toContain('[1]button "Submit"');
    await expect(page.click('1')).resolves.toEqual({ matches_n: 1, match_level: 'exact' });

    expect(page.cdp).toHaveBeenNthCalledWith(1, 'Accessibility.enable', {});
    expect(page.cdp).toHaveBeenNthCalledWith(2, 'Accessibility.getFullAXTree', {});
    expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', {});
    expect(page.cdp).toHaveBeenCalledWith('Page.getFrameTree', {});
    expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 10 });
    expect(page.nativeClick).toHaveBeenCalledWith(30, 30);
    expect(page.scripts).toHaveLength(0);
  });

  it('adds same-origin iframe AX refs and clicks them by frame-scoped backend node', async () => {
    const page = new ActionPage();
    page.nativeClick = vi.fn().mockResolvedValue(undefined);
    page.cdp = vi.fn(async (method: string, params?: Record<string, unknown>) => {
      if (method === 'Accessibility.getFullAXTree' && params?.frameId === 'same-frame') {
        return {
          nodes: [
            { nodeId: '1', role: { value: 'RootWebArea' }, name: { value: 'Embedded' }, childIds: ['2'] },
            { nodeId: '2', role: { value: 'button' }, name: { value: 'Frame Save' }, backendDOMNodeId: 20 },
          ],
        };
      }
      if (method === 'Accessibility.getFullAXTree' && params?.frameId === 'cross-frame') {
        return {
          nodes: [
            { nodeId: '1', role: { value: 'RootWebArea' }, name: { value: 'Cross' }, childIds: ['2'] },
            { nodeId: '2', role: { value: 'button' }, name: { value: 'Cross Save' }, backendDOMNodeId: 30 },
          ],
        };
      }
      if (method === 'Accessibility.getFullAXTree') {
        return {
          nodes: [
            { nodeId: '1', role: { value: 'RootWebArea' }, name: { value: 'Demo' }, childIds: ['2'] },
            { nodeId: '2', role: { value: 'button' }, name: { value: 'Main Save' }, backendDOMNodeId: 10 },
          ],
        };
      }
      if (method === 'Page.getFrameTree') {
        return {
          frameTree: {
            frame: { id: 'root', url: 'https://app.example/' },
            childFrames: [
              { frame: { id: 'same-frame', url: 'https://app.example/embed' } },
              { frame: { id: 'cross-frame', url: 'https://other.example/embed' } },
            ],
          },
        };
      }
      if (method === 'DOM.getBoxModel') {
        return { model: { content: [100, 200, 140, 200, 140, 220, 100, 220] } };
      }
      return {};
    });

    const snapshot = await page.snapshot({ source: 'ax' }) as string;
    expect(snapshot).toContain('[1]button "Main Save"');
    expect(snapshot).toContain('frame "https://app.example/embed":');
    expect(snapshot).toContain('[2]button "Frame Save"');
    expect(snapshot).toContain('frame "https://other.example/embed":');
    expect(snapshot).toContain('[3]button "Cross Save"');

    await expect(page.click('2')).resolves.toEqual({ matches_n: 1, match_level: 'exact' });

    expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'same-frame' });
    expect(page.cdp).toHaveBeenCalledWith('Accessibility.enable', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
    expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
    expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 20 });
    expect(page.nativeClick).toHaveBeenCalledWith(120, 210);
  });

  it('clicks cross-origin AX refs through a frame-target CDP route', async () => {
    const page = new ActionPage();
    page.nativeClick = vi.fn().mockResolvedValue(undefined);
    page.cdp = vi.fn(async (method: string, params?: Record<string, unknown>) => {
      if (method === 'Page.getFrameTree') {
        return {
          frameTree: {
            frame: { id: 'root', url: 'https://app.example/' },
            childFrames: [{ frame: { id: 'cross-frame', url: 'https://other.example/embed' } }],
          },
        };
      }
      if (method === 'Accessibility.getFullAXTree' && params?.sessionId === 'target') {
        return {
          nodes: [
            { nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
            { nodeId: '2', role: { value: 'button' }, name: { value: 'Cross Save' }, backendDOMNodeId: 99 },
          ],
        };
      }
      if (method === 'Accessibility.getFullAXTree') {
        return { nodes: [{ nodeId: '1', role: { value: 'RootWebArea' } }] };
      }
      if (method === 'DOM.getBoxModel') {
        return { model: { content: [300, 400, 340, 400, 340, 420, 300, 420] } };
      }
      return {};
    });

    const snapshot = await page.snapshot({ source: 'ax' }) as string;
    expect(snapshot).toContain('[1]button "Cross Save"');
    await expect(page.click('1')).resolves.toEqual({ matches_n: 1, match_level: 'exact' });

    expect(page.cdp).toHaveBeenCalledWith('Accessibility.enable', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
    expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
    expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 99, frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
    expect(page.nativeClick).toHaveBeenCalledWith(320, 410);
  });

  it('enables Accessibility in cross-origin frame target sessions before stale AX recovery', async () => {
    const page = new ActionPage();
    page.nativeClick = vi.fn().mockResolvedValue(undefined);
    let crossAxCalls = 0;
    page.cdp = vi.fn(async (method: string, params?: Record<string, unknown>) => {
      if (method === 'Page.getFrameTree') {
        return {
          frameTree: {
            frame: { id: 'root', url: 'https://app.example/' },
            childFrames: [{ frame: { id: 'cross-frame', url: 'https://other.example/embed' } }],
          },
        };
      }
      if (method === 'Accessibility.getFullAXTree' && params?.sessionId === 'target') {
        crossAxCalls++;
        const backendDOMNodeId = crossAxCalls === 1 ? 99 : 100;
        return {
          nodes: [
            { nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
            { nodeId: '2', role: { value: 'button' }, name: { value: 'Cross Save' }, backendDOMNodeId },
          ],
        };
      }
      if (method === 'Accessibility.getFullAXTree') {
        return { nodes: [{ nodeId: '1', role: { value: 'RootWebArea' } }] };
      }
      if (method === 'DOM.getBoxModel') {
        if (params?.backendNodeId === 99) throw new Error('No node with given id found');
        return { model: { content: [300, 400, 340, 400, 340, 420, 300, 420] } };
      }
      return {};
    });

    await page.snapshot({ source: 'ax' });
    await expect(page.click('1')).resolves.toEqual({ matches_n: 1, match_level: 'reidentified' });

    expect(page.cdp).toHaveBeenCalledWith('Accessibility.enable', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
    expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
    expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 99, frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
    expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 100, frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
    expect(page.nativeClick).toHaveBeenCalledWith(320, 410);
  });

  it('recovers stale iframe AX refs inside the original frame', async () => {
    const page = new ActionPage();
    page.nativeClick = vi.fn().mockResolvedValue(undefined);
    let iframeAxCalls = 0;
    page.cdp = vi.fn(async (method: string, params?: Record<string, unknown>) => {
      if (method === 'Page.getFrameTree') {
        return {
          frameTree: {
            frame: { id: 'root', url: 'https://app.example/' },
            childFrames: [{ frame: { id: 'same-frame', url: 'https://app.example/embed' } }],
          },
        };
      }
      if (method === 'Accessibility.getFullAXTree' && params?.frameId === 'same-frame') {
        iframeAxCalls++;
        return {
          nodes: [
            { nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
            { nodeId: '2', role: { value: 'button' }, name: { value: 'Frame Save' }, backendDOMNodeId: iframeAxCalls === 1 ? 20 : 42 },
          ],
        };
      }
      if (method === 'Accessibility.getFullAXTree') {
        return {
          nodes: [
            { nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
            { nodeId: '2', role: { value: 'button' }, name: { value: 'Main Save' }, backendDOMNodeId: 10 },
          ],
        };
      }
      if (method === 'DOM.getBoxModel') {
        if (params?.backendNodeId === 20) throw new Error('No node with given id found');
        return { model: { content: [200, 300, 240, 300, 240, 320, 200, 320] } };
      }
      return {};
    });

    await page.snapshot({ source: 'ax' });
    await expect(page.click('2')).resolves.toEqual({ matches_n: 1, match_level: 'reidentified' });

    expect(page.cdp).toHaveBeenCalledWith('Accessibility.enable', {});
    expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'same-frame' });
    expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 20 });
    expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 42 });
    expect(page.nativeClick).toHaveBeenCalledWith(220, 310);
  });

  it('recovers stale AX refs by role/name/nth before clicking', async () => {
    const page = new ActionPage();
    page.nativeClick = vi.fn().mockResolvedValue(undefined);
    let axCalls = 0;
    page.cdp = vi.fn(async (method: string, params?: Record<string, unknown>) => {
      if (method === 'Accessibility.getFullAXTree') {
        axCalls++;
        return {
          nodes: [
            { nodeId: '1', role: { value: 'RootWebArea' }, name: { value: 'Demo' }, childIds: ['2'] },
            { nodeId: '2', role: { value: 'button' }, name: { value: 'Submit' }, backendDOMNodeId: axCalls === 1 ? 10 : 42 },
          ],
        };
      }
      if (method === 'DOM.getBoxModel') {
        if (params?.backendNodeId === 10) throw new Error('No node with given id found');
        return { model: { content: [100, 200, 140, 200, 140, 220, 100, 220] } };
      }
      return {};
    });

    await page.snapshot({ source: 'ax' });
    await expect(page.click('1')).resolves.toEqual({ matches_n: 1, match_level: 'reidentified' });

    expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 10 });
    expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 42 });
    expect(page.nativeClick).toHaveBeenCalledWith(120, 210);
  });

  it('recovers AX refs across 10 repeated React-style rerenders', async () => {
    const page = new ActionPage();
    page.nativeClick = vi.fn().mockResolvedValue(undefined);
    let currentBackendId = 100;
    const staleBackendIds = new Set<number>();
    page.cdp = vi.fn(async (method: string, params?: Record<string, unknown>) => {
      if (method === 'Accessibility.getFullAXTree') {
        return {
          nodes: [
            { nodeId: '1', role: { value: 'RootWebArea' }, name: { value: 'Demo' }, childIds: ['2'] },
            { nodeId: '2', role: { value: 'button' }, name: { value: 'Submit' }, backendDOMNodeId: currentBackendId },
          ],
        };
      }
      if (method === 'DOM.getBoxModel') {
        const id = params?.backendNodeId as number;
        if (staleBackendIds.has(id)) throw new Error('No node with given id found');
        return { model: { content: [id, id, id + 20, id, id + 20, id + 10, id, id + 10] } };
      }
      return {};
    });

    await page.snapshot({ source: 'ax' });
    for (let i = 0; i < 10; i++) {
      staleBackendIds.add(currentBackendId);
      currentBackendId += 1;
      await expect(page.click('1')).resolves.toEqual({ matches_n: 1, match_level: 'reidentified' });
    }

    expect(page.nativeClick).toHaveBeenCalledTimes(10);
  });

  it('falls back to JS el.click() when nativeClick is unavailable', async () => {
    const page = new ActionPage();
    page.results = [
      resolveOk,
      { x: 50, y: 100, w: 200, h: 32, visible: true },
      { status: 'clicked', x: 50, y: 100 },
    ];

    await page.click('#save');

    expect(page.scripts).toHaveLength(3);
    expect(page.scripts[1]).toContain('getBoundingClientRect');
    expect(page.scripts[2]).toContain('el.click()');
  });

  it('falls back to JS el.click() when rect is zero-area', async () => {
    const page = new ActionPage();
    page.nativeClick = vi.fn().mockResolvedValue(undefined);
    page.results = [
      resolveOk,
      { x: 0, y: 0, w: 0, h: 0, visible: false },
      { status: 'clicked', x: 0, y: 0 },
    ];

    await page.click('#hidden');

    expect(page.nativeClick).not.toHaveBeenCalled();
    expect(page.scripts).toHaveLength(3);
    expect(page.scripts[2]).toContain('el.click()');
  });

  it('retries CDP click when JS path throws but yields coordinates', async () => {
    const page = new ActionPage();
    const nativeClick = vi.fn()
      .mockRejectedValueOnce(new Error('cdp transient'))
      .mockResolvedValueOnce(undefined);
    page.nativeClick = nativeClick;
    page.results = [
      resolveOk,
      { x: 10, y: 20, w: 100, h: 30, visible: true },
      { status: 'js_failed', x: 10, y: 20, error: 'click intercepted' },
    ];

    await page.click('#flaky');

    expect(nativeClick).toHaveBeenCalledTimes(2);
    expect(nativeClick).toHaveBeenNthCalledWith(1, 10, 20);
    expect(nativeClick).toHaveBeenNthCalledWith(2, 10, 20);
  });

  it('hovers via native CDP mouseMoved when coordinates are available', async () => {
    const page = new ActionPage();
    page.cdp = vi.fn().mockResolvedValue({});
    page.results = [resolveOk, { x: 70, y: 80, w: 100, h: 20, visible: true }];

    await expect(page.hover('#menu')).resolves.toEqual({ matches_n: 1, match_level: 'exact' });

    expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 70, y: 80 });
    expect(page.scripts.at(-1)).toContain('getBoundingClientRect');
  });

  it('focuses through CDP DOM.focus when available', async () => {
    const page = new ActionPage();
    page.cdp = vi.fn(async (method: string) => {
      if (method === 'DOM.getDocument') return { root: { nodeId: 1 } };
      if (method === 'DOM.querySelector') return { nodeId: 9 };
      return {};
    });
    page.results = [resolveOk, true];
    page.withArgsResults = [{ ok: true }, undefined];

    await expect(page.focus('#email')).resolves.toEqual({ focused: true, matches_n: 1, match_level: 'exact' });

    expect(page.cdp).toHaveBeenCalledWith('DOM.focus', { nodeId: 9 });
  });

  it('verifies CDP focus and falls back to DOM focus when focus did not stick', async () => {
    const page = new ActionPage();
    page.cdp = vi.fn(async (method: string) => {
      if (method === 'DOM.getDocument') return { root: { nodeId: 1 } };
      if (method === 'DOM.querySelector') return { nodeId: 9 };
      return {};
    });
    page.results = [resolveOk, false, true];
    page.withArgsResults = [{ ok: true }, undefined];

    await expect(page.focus('#email')).resolves.toEqual({ focused: true, matches_n: 1, match_level: 'exact' });

    expect(page.cdp).toHaveBeenCalledWith('DOM.focus', { nodeId: 9 });
    expect(page.scripts.at(-2)).toContain('document.activeElement === el');
    expect(page.scripts.at(-1)).toContain('el.focus');
  });

  it('double-clicks via native CDP mouse events', async () => {
    const page = new ActionPage();
    page.cdp = vi.fn().mockResolvedValue({});
    page.results = [resolveOk, { x: 20, y: 30, w: 100, h: 20, visible: true }];

    await expect(page.dblClick('#row')).resolves.toEqual({ matches_n: 1, match_level: 'exact' });

    expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 20, y: 30 });
    expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mousePressed', x: 20, y: 30, button: 'left', clickCount: 2 });
    expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 20, y: 30, button: 'left', clickCount: 2 });
  });

  it('checks a checkbox only when its current state differs', async () => {
    const page = new ActionPage();
    page.nativeClick = vi.fn().mockResolvedValue(undefined);
    page.results = [
      resolveOk,
      { ok: true, checked: false, disabled: false, kind: 'checkbox' },
      resolveOk,
      { x: 20, y: 30, w: 40, h: 20, visible: true },
      { ok: true, checked: true, disabled: false, kind: 'checkbox' },
    ];

    await expect(page.setChecked('#agree', true)).resolves.toEqual({
      checked: true,
      changed: true,
      matches_n: 1,
      match_level: 'exact',
      kind: 'checkbox',
    });

    expect(page.nativeClick).toHaveBeenCalledWith(20, 30);
  });

  it('does not click a checkbox that already has the requested state', async () => {
    const page = new ActionPage();
    page.nativeClick = vi.fn().mockResolvedValue(undefined);
    page.results = [resolveOk, { ok: true, checked: false, disabled: false, kind: 'checkbox' }];

    await expect(page.setChecked('#agree', false)).resolves.toEqual({
      checked: false,
      changed: false,
      matches_n: 1,
      match_level: 'exact',
      kind: 'checkbox',
    });

    expect(page.nativeClick).not.toHaveBeenCalled();
  });

  it('rejects non-checkable targets with a structured error', async () => {
    const page = new ActionPage();
    page.results = [resolveOk, { ok: false, reason: 'not_checkable', tag: 'button' }];

    const err = await page.setChecked('button', true).catch((error: unknown) => error);

    expect(err).toBeInstanceOf(TargetError);
    expect((err as TargetError).code).toBe('not_checkable');
  });

  it('rejects attempts to uncheck a radio button directly', async () => {
    const page = new ActionPage();
    page.results = [resolveOk, { ok: true, checked: true, disabled: false, kind: 'radio' }];

    const err = await page.setChecked('#radio', false).catch((error: unknown) => error);

    expect(err).toBeInstanceOf(TargetError);
    expect((err as TargetError).code).toBe('not_checkable');
    expect((err as TargetError).hint).toContain('Select another radio');
  });

  it('treats ARIA radio controls like radio buttons', async () => {
    const page = new ActionPage();
    page.results = [resolveOk, { ok: true, checked: true, disabled: false, kind: 'menuitemradio' }];

    const err = await page.setChecked('[role="menuitemradio"]', false).catch((error: unknown) => error);

    expect(err).toBeInstanceOf(TargetError);
    expect((err as TargetError).code).toBe('not_checkable');
    expect((err as TargetError).hint).toContain('Select another radio');
  });

  it('uploads files through setFileInput using a temporary marker selector', async () => {
    const page = new ActionPage();
    page.setFileInput = vi.fn().mockResolvedValue(undefined);
    page.results = [
      resolveOk,
      ['receipt.pdf'],
    ];
    page.withArgsResults = [{ ok: true, multiple: false, accept: 'application/pdf' }, undefined];

    await expect(page.uploadFiles('#file', ['/tmp/receipt.pdf'])).resolves.toEqual({
      uploaded: true,
      files: 1,
      file_names: ['receipt.pdf'],
      target: '#file',
      matches_n: 1,
      match_level: 'exact',
      multiple: false,
      accept: 'application/pdf',
    });

    expect(page.setFileInput).toHaveBeenCalledWith(['/tmp/receipt.pdf'], expect.stringMatching(/data-opencli-upload-target/));
    expect(page.withArgs.at(0)).toMatchObject({ markerAttr: 'data-opencli-upload-target' });
    expect(page.withArgs.at(-1)).toMatchObject({ markerAttr: 'data-opencli-upload-target' });
  });

  it('rejects non-file-input upload targets with a structured error', async () => {
    const page = new ActionPage();
    page.setFileInput = vi.fn().mockResolvedValue(undefined);
    page.results = [resolveOk];
    page.withArgsResults = [{ ok: false, tag: 'button', type: '' }];

    const err = await page.uploadFiles('button', ['/tmp/receipt.pdf']).catch((error: unknown) => error);

    expect(err).toBeInstanceOf(TargetError);
    expect((err as TargetError).code).toBe('not_file_input');
    expect(page.setFileInput).not.toHaveBeenCalled();
  });

  it('rejects multiple files for a single-file input before mutating files', async () => {
    const page = new ActionPage();
    page.setFileInput = vi.fn().mockResolvedValue(undefined);
    page.results = [resolveOk];
    page.withArgsResults = [{ ok: true, multiple: false, accept: '' }, undefined];

    const err = await page.uploadFiles('#file', ['/tmp/a.pdf', '/tmp/b.pdf']).catch((error: unknown) => error);

    expect(err).toBeInstanceOf(TargetError);
    expect((err as TargetError).code).toBe('not_file_input');
    expect(page.setFileInput).not.toHaveBeenCalled();
  });

  it('drags between two resolved element centers via native CDP mouse events', async () => {
    const page = new ActionPage();
    page.cdp = vi.fn().mockResolvedValue({});
    page.results = [
      resolveOk,
      { x: 10, y: 20, w: 30, h: 20, visible: true },
      { ok: true, matches_n: 2, match_level: 'stable' },
      {
        source: { x: 10, y: 20, w: 30, h: 20, visible: true },
        target: { x: 110, y: 120, w: 40, h: 30, visible: true },
      },
    ];

    await expect(page.drag('#card', '.lane', { to: { nth: 1 } })).resolves.toEqual({
      dragged: true,
      source: '#card',
      target: '.lane',
      source_matches_n: 1,
      target_matches_n: 2,
      source_match_level: 'exact',
      target_match_level: 'stable',
    });

    expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 10, y: 20 });
    expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mousePressed', x: 10, y: 20, button: 'left', clickCount: 1 });
    expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 60, y: 70, button: 'left', buttons: 1 });
    expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 110, y: 120, button: 'left', buttons: 1 });
    expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 110, y: 120, button: 'left', clickCount: 1 });
  });

  it('presses key chords through native CDP key events when available', async () => {
    const page = new ActionPage();
    page.nativeKeyPress = vi.fn().mockResolvedValue(undefined);

    await page.pressKey('Control+a');

    expect(page.nativeKeyPress).toHaveBeenCalledWith('a', ['Ctrl']);
    expect(page.scripts).toHaveLength(0);
  });

  it('falls back to synthetic keyboard events with parsed modifiers', async () => {
    const page = new ActionPage();

    await page.pressKey('Meta+N');

    expect(page.scripts).toHaveLength(1);
    expect(page.scripts[0]).toContain('key: "N"');
    expect(page.scripts[0]).toContain('metaKey: true');
  });
});

describe('BasePage.evaluateWithArgs scoping', () => {
  class EvalCapturePage extends BasePage {
    scripts: string[] = [];
    async goto(): Promise<void> {}
    async evaluate<T = unknown>(js: string): Promise<T>;
    async evaluate<Args extends unknown[], T>(fn: BrowserEvaluateFunction<Args, T>, ...args: Args): Promise<Awaited<T>>;
    async evaluate(input: string | BrowserEvaluateFunction<unknown[], unknown>): Promise<unknown> {
      const code = typeof input === 'string' ? input : input.toString();
      this.scripts.push(code);
      if (typeof input !== 'string') return null;
      return globalThis.eval(input);
    }
    async getCookies(): Promise<[]> { return []; }
    async screenshot(): Promise<string> { return ''; }
    async tabs(): Promise<unknown[]> { return []; }
    async selectTab(): Promise<void> {}
  }

  it('wraps declarations in a lexical block to avoid global const redeclaration', async () => {
    const page = new EvalCapturePage();
    const result = await page.evaluateWithArgs('(() => markerAttr)()', { markerAttr: 'test-value' });

    expect(result).toBe('test-value');
    expect(page.scripts).toHaveLength(1);
    const code = page.scripts[0];
    // Must be wrapped in a lexical block, not bare top-level const.
    expect(code).toMatch(/^\{\n/);
    expect(code).toContain('const markerAttr = "test-value"');
  });

  it('allows same arg name across multiple calls without conflict', async () => {
    const page = new EvalCapturePage();
    await expect(page.evaluateWithArgs('(() => x)()', { x: 'first' })).resolves.toBe('first');
    await expect(page.evaluateWithArgs('(() => x)()', { x: 'second' })).resolves.toBe('second');

    expect(page.scripts).toHaveLength(2);
    // Both should be self-contained blocks, not polluting global scope.
    expect(page.scripts[0]).toContain('const x = "first"');
    expect(page.scripts[1]).toContain('const x = "second"');
    expect(page.scripts[0]).toMatch(/^\{\n/);
    expect(page.scripts[1]).toMatch(/^\{\n/);
  });

  it('preserves completion value semantics for statement snippets', async () => {
    const page = new EvalCapturePage();

    await expect(page.evaluateWithArgs(`
      const local = value + 1;
      local * 2
    `, { value: 20 })).resolves.toBe(42);
  });
});
