/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import {
  WebFetchTool,
  parsePrompt,
  convertGithubUrlToRaw,
  normalizeUrl,
} from './web-fetch.js';
import type { Config } from '../config/config.js';
import { ApprovalMode } from '../policy/types.js';
import { ToolConfirmationOutcome } from './tools.js';
import { ToolErrorType } from './tool-error.js';
import {
  createMockMessageBus,
  getMockMessageBusInstance,
} from '../test-utils/mock-message-bus.js';
import * as fetchUtils from '../utils/fetch.js';
import { MessageBus } from '../confirmation-bus/message-bus.js';
import { PolicyEngine } from '../policy/policy-engine.js';
import {
  MessageBusType,
  type ToolConfirmationResponse,
} from '../confirmation-bus/types.js';
import { randomUUID } from 'node:crypto';
import {
  logWebFetchFallbackAttempt,
  WebFetchFallbackAttemptEvent,
} from '../telemetry/index.js';
import { convert } from 'html-to-text';

const mockGenerateContent = vi.fn();
const mockGetGeminiClient = vi.fn(() => ({
  generateContent: mockGenerateContent,
}));

vi.mock('html-to-text', () => ({
  convert: vi.fn((text) => `Converted: ${text}`),
}));

vi.mock('../telemetry/index.js', () => ({
  logWebFetchFallbackAttempt: vi.fn(),
  WebFetchFallbackAttemptEvent: vi.fn((reason) => ({ reason })),
}));

vi.mock('../utils/fetch.js', async (importOriginal) => {
  const actual = await importOriginal<typeof fetchUtils>();
  return {
    ...actual,
    fetchWithTimeout: vi.fn(),
    isPrivateIp: vi.fn(),
  };
});

vi.mock('node:crypto', () => ({
  randomUUID: vi.fn(),
}));

/**
 * Helper to mock fetchWithTimeout with URL matching.
 */
const mockFetch = (url: string, response: Partial<Response> | Error) =>
  vi
    .spyOn(fetchUtils, 'fetchWithTimeout')
    .mockImplementation(async (actualUrl) => {
      if (actualUrl !== url) {
        throw new Error(
          `Unexpected fetch URL: expected "${url}", got "${actualUrl}"`,
        );
      }
      if (response instanceof Error) {
        throw response;
      }

      const headers = response.headers || new Headers();

      // If we have text/arrayBuffer but no body, create a body mock
      let body = response.body;
      if (!body) {
        let content: Uint8Array | undefined;
        if (response.text) {
          const text = await response.text();
          content = new TextEncoder().encode(text);
        } else if (response.arrayBuffer) {
          const ab = await response.arrayBuffer();
          content = new Uint8Array(ab);
        }

        if (content) {
          body = {
            getReader: () => {
              let sent = false;
              return {
                read: async () => {
                  if (sent) return { done: true, value: undefined };
                  sent = true;
                  return { done: false, value: content };
                },
                releaseLock: () => {},
                cancel: async () => {},
              };
            },
          } as unknown as ReadableStream;
        }
      }

      return {
        ok: response.status ? response.status < 400 : true,
        status: 200,
        headers,
        text: response.text || (() => Promise.resolve('')),
        arrayBuffer:
          response.arrayBuffer || (() => Promise.resolve(new ArrayBuffer(0))),
        body: body || {
          getReader: () => ({
            read: async () => ({ done: true, value: undefined }),
            releaseLock: () => {},
            cancel: async () => {},
          }),
        },
        ...response,
      } as unknown as Response;
    });

describe('normalizeUrl', () => {
  it('should lowercase hostname', () => {
    expect(normalizeUrl('https://EXAMPLE.com/Path')).toBe(
      'https://example.com/Path',
    );
  });

  it('should remove trailing slash except for root', () => {
    expect(normalizeUrl('https://example.com/path/')).toBe(
      'https://example.com/path',
    );
    expect(normalizeUrl('https://example.com/')).toBe('https://example.com/');
  });

  it('should remove default ports', () => {
    expect(normalizeUrl('http://example.com:80/')).toBe('http://example.com/');
    expect(normalizeUrl('https://example.com:443/')).toBe(
      'https://example.com/',
    );
    expect(normalizeUrl('https://example.com:8443/')).toBe(
      'https://example.com:8443/',
    );
  });

  it('should handle invalid URLs gracefully', () => {
    expect(normalizeUrl('not-a-url')).toBe('not-a-url');
  });
});

describe('parsePrompt', () => {
  it('should extract valid URLs separated by whitespace', () => {
    const prompt = 'Go to https://example.com and http://google.com';
    const { validUrls, errors } = parsePrompt(prompt);

    expect(errors).toHaveLength(0);
    expect(validUrls).toHaveLength(2);
    expect(validUrls[0]).toBe('https://example.com/');
    expect(validUrls[1]).toBe('http://google.com/');
  });

  it('should accept URLs with trailing punctuation', () => {
    const prompt = 'Check https://example.com.';
    const { validUrls, errors } = parsePrompt(prompt);

    expect(errors).toHaveLength(0);
    expect(validUrls).toHaveLength(1);
    expect(validUrls[0]).toBe('https://example.com./');
  });

  it.each([
    {
      name: 'URLs wrapped in punctuation',
      prompt: 'Read (https://example.com)',
      expectedErrorContent: ['Malformed URL detected', '(https://example.com)'],
    },
    {
      name: 'unsupported protocols (httpshttps://)',
      prompt: 'Summarize httpshttps://github.com/JuliaLang/julia/issues/58346',
      expectedErrorContent: [
        'Unsupported protocol',
        'httpshttps://github.com/JuliaLang/julia/issues/58346',
      ],
    },
    {
      name: 'unsupported protocols (ftp://)',
      prompt: 'ftp://example.com/file.txt',
      expectedErrorContent: ['Unsupported protocol'],
    },
    {
      name: 'malformed URLs (http://)',
      prompt: 'http://',
      expectedErrorContent: ['Malformed URL detected'],
    },
  ])('should detect $name as errors', ({ prompt, expectedErrorContent }) => {
    const { validUrls, errors } = parsePrompt(prompt);

    expect(validUrls).toHaveLength(0);
    expect(errors).toHaveLength(1);
    expectedErrorContent.forEach((content) => {
      expect(errors[0]).toContain(content);
    });
  });

  it('should handle prompts with no URLs', () => {
    const prompt = 'hello world';
    const { validUrls, errors } = parsePrompt(prompt);

    expect(validUrls).toHaveLength(0);
    expect(errors).toHaveLength(0);
  });

  it('should handle mixed valid and invalid URLs', () => {
    const prompt = 'Valid: https://google.com, Invalid: ftp://bad.com';
    const { validUrls, errors } = parsePrompt(prompt);

    expect(validUrls).toHaveLength(1);
    expect(validUrls[0]).toBe('https://google.com,/');
    expect(errors).toHaveLength(1);
    expect(errors[0]).toContain('ftp://bad.com');
  });
});

describe('convertGithubUrlToRaw', () => {
  it('should convert valid github blob urls', () => {
    expect(
      convertGithubUrlToRaw('https://github.com/user/repo/blob/main/README.md'),
    ).toBe('https://raw.githubusercontent.com/user/repo/main/README.md');
  });

  it('should not convert non-blob github urls', () => {
    expect(convertGithubUrlToRaw('https://github.com/user/repo')).toBe(
      'https://github.com/user/repo',
    );
  });

  it('should not convert urls with similar domain names', () => {
    expect(
      convertGithubUrlToRaw('https://mygithub.com/user/repo/blob/main'),
    ).toBe('https://mygithub.com/user/repo/blob/main');
  });

  it('should only replace the /blob/ that separates repo from branch', () => {
    expect(
      convertGithubUrlToRaw('https://github.com/blob/repo/blob/main/test.ts'),
    ).toBe('https://raw.githubusercontent.com/blob/repo/main/test.ts');
  });

  it('should not convert urls if blob is not in path', () => {
    expect(
      convertGithubUrlToRaw('https://github.com/user/repo/tree/main'),
    ).toBe('https://github.com/user/repo/tree/main');
  });

  it('should handle invalid urls gracefully', () => {
    expect(convertGithubUrlToRaw('not-a-url')).toBe('not-a-url');
  });
});

describe('WebFetchTool', () => {
  let mockConfig: Config;
  let bus: MessageBus;

  beforeEach(() => {
    vi.resetAllMocks();
    bus = createMockMessageBus();
    getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user';
    mockConfig = {
      getApprovalMode: vi.fn(),
      setApprovalMode: vi.fn(),
      getProxy: vi.fn(),
      getGeminiClient: mockGetGeminiClient,
      get config() {
        return this;
      },
      get geminiClient() {
        return mockGetGeminiClient();
      },
      getRetryFetchErrors: vi.fn().mockReturnValue(false),
      getMaxAttempts: vi.fn().mockReturnValue(3),
      getDirectWebFetch: vi.fn().mockReturnValue(false),
      modelConfigService: {
        getResolvedConfig: vi.fn().mockImplementation(({ model }) => ({
          model,
          generateContentConfig: {},
        })),
      },
      isInteractive: () => false,
      isContextManagementEnabled: vi.fn().mockReturnValue(false),
    } as unknown as Config;
  });

  describe('validateToolParamValues', () => {
    describe('standard mode', () => {
      it.each([
        {
          name: 'empty prompt',
          prompt: '',
          expectedError: "The 'prompt' parameter cannot be empty",
        },
        {
          name: 'prompt with no URLs',
          prompt: 'hello world',
          expectedError: "The 'prompt' must contain at least one valid URL",
        },
        {
          name: 'prompt with malformed URLs',
          prompt: 'fetch httpshttps://example.com',
          expectedError: 'Error(s) in prompt URLs:',
        },
      ])('should throw if $name', ({ prompt, expectedError }) => {
        const tool = new WebFetchTool(mockConfig, bus);
        expect(() => tool.build({ prompt })).toThrow(expectedError);
      });

      it('should pass if prompt contains at least one valid URL', () => {
        const tool = new WebFetchTool(mockConfig, bus);
        expect(() =>
          tool.build({ prompt: 'fetch https://example.com' }),
        ).not.toThrow();
      });
    });

    describe('experimental mode', () => {
      beforeEach(() => {
        vi.spyOn(mockConfig, 'getDirectWebFetch').mockReturnValue(true);
      });

      it('should throw if url is missing', () => {
        const tool = new WebFetchTool(mockConfig, bus);
        expect(() => tool.build({ prompt: 'foo' })).toThrow(
          "params must have required property 'url'",
        );
      });

      it('should throw if url is invalid', () => {
        const tool = new WebFetchTool(mockConfig, bus);
        expect(() => tool.build({ url: 'not-a-url' })).toThrow(
          'Invalid URL: "not-a-url"',
        );
      });

      it('should pass if url is valid', () => {
        const tool = new WebFetchTool(mockConfig, bus);
        expect(() => tool.build({ url: 'https://example.com' })).not.toThrow();
      });
    });
  });

  describe('getSchema', () => {
    it('should return standard schema by default', () => {
      const tool = new WebFetchTool(mockConfig, bus);
      const schema = tool.getSchema();
      expect(schema.parametersJsonSchema).toHaveProperty('properties.prompt');
      expect(schema.parametersJsonSchema).not.toHaveProperty('properties.url');
    });

    it('should return experimental schema when enabled', () => {
      vi.spyOn(mockConfig, 'getDirectWebFetch').mockReturnValue(true);
      const tool = new WebFetchTool(mockConfig, bus);
      const schema = tool.getSchema();
      expect(schema.parametersJsonSchema).toHaveProperty('properties.url');
      expect(schema.parametersJsonSchema).not.toHaveProperty(
        'properties.prompt',
      );
      expect(schema.parametersJsonSchema).toHaveProperty('required', ['url']);
    });
  });

  describe('execute', () => {
    it('should return WEB_FETCH_PROCESSING_ERROR on rate limit exceeded', async () => {
      vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
      mockGenerateContent.mockResolvedValue({
        candidates: [{ content: { parts: [{ text: 'response' }] } }],
      });
      const tool = new WebFetchTool(mockConfig, bus);
      const params = { prompt: 'fetch https://ratelimit.example.com' };
      const invocation = tool.build(params);

      // Execute 10 times to hit the limit
      for (let i = 0; i < 10; i++) {
        await invocation.execute({ abortSignal: new AbortController().signal });
      }

      // The 11th time should fail due to rate limit
      const result = await invocation.execute({
        abortSignal: new AbortController().signal,
      });
      expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_PROCESSING_ERROR);
      expect(result.error?.message).toContain(
        'All requested URLs were skipped',
      );
    });

    it('should skip rate-limited URLs but fetch others', async () => {
      vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);

      const tool = new WebFetchTool(mockConfig, bus);
      const params = {
        prompt: 'fetch https://ratelimit-multi.com and https://healthy.com',
      };
      const invocation = tool.build(params);

      // Hit rate limit for one host
      for (let i = 0; i < 10; i++) {
        mockGenerateContent.mockResolvedValueOnce({
          candidates: [{ content: { parts: [{ text: 'response' }] } }],
        });
        await tool
          .build({ prompt: 'fetch https://ratelimit-multi.com' })
          .execute({ abortSignal: new AbortController().signal });
      }
      // 11th call - should be rate limited and not use a mock
      await tool
        .build({ prompt: 'fetch https://ratelimit-multi.com' })
        .execute({ abortSignal: new AbortController().signal });

      mockGenerateContent.mockResolvedValueOnce({
        candidates: [{ content: { parts: [{ text: 'healthy response' }] } }],
      });

      const result = await invocation.execute({
        abortSignal: new AbortController().signal,
      });
      expect(result.llmContent).toContain('healthy response');
      expect(result.llmContent).toContain(
        '[Warning] The following URLs were skipped:',
      );
      expect(result.llmContent).toContain(
        '[Rate limit exceeded] https://ratelimit-multi.com/',
      );
    });

    it('should skip private or local URLs but fetch others and log telemetry', async () => {
      vi.mocked(fetchUtils.isPrivateIp).mockImplementation(
        (url) => url === 'https://private.com/',
      );

      const tool = new WebFetchTool(mockConfig, bus);
      const params = {
        prompt:
          'fetch https://private.com and https://healthy.com and http://localhost',
      };
      const invocation = tool.build(params);

      mockGenerateContent.mockResolvedValueOnce({
        candidates: [{ content: { parts: [{ text: 'healthy response' }] } }],
      });

      const result = await invocation.execute({
        abortSignal: new AbortController().signal,
      });

      expect(logWebFetchFallbackAttempt).toHaveBeenCalledTimes(2);
      expect(logWebFetchFallbackAttempt).toHaveBeenCalledWith(
        expect.anything(),
        expect.objectContaining({ reason: 'private_ip_skipped' }),
      );

      expect(result.llmContent).toContain('healthy response');
      expect(result.llmContent).toContain(
        '[Warning] The following URLs were skipped:',
      );
      expect(result.llmContent).toContain(
        '[Blocked Host] https://private.com/',
      );
      expect(result.llmContent).toContain('[Blocked Host] http://localhost');
    });

    it('should fallback to all public URLs if primary fails', async () => {
      vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);

      // Primary fetch fails
      mockGenerateContent.mockRejectedValueOnce(new Error('primary fail'));

      // Mock fallback fetch for BOTH URLs
      mockFetch('https://url1.com/', {
        text: () => Promise.resolve('content 1'),
      });
      mockFetch('https://url2.com/', {
        text: () => Promise.resolve('content 2'),
      });

      // Mock fallback LLM call
      mockGenerateContent.mockResolvedValueOnce({
        candidates: [
          { content: { parts: [{ text: 'fallback processed response' }] } },
        ],
      });

      const tool = new WebFetchTool(mockConfig, bus);
      const params = {
        prompt: 'fetch https://url1.com and https://url2.com/',
      };
      const invocation = tool.build(params);
      const result = await invocation.execute({
        abortSignal: new AbortController().signal,
      });

      expect(result.llmContent).toBe(
        '<untrusted_context>\nfallback processed response\n</untrusted_context>',
      );
      expect(result.returnDisplay).toContain(
        'URL(s) processed using fallback fetch',
      );
    });

    it('should NOT include private URLs in fallback', async () => {
      vi.mocked(fetchUtils.isPrivateIp).mockImplementation(
        (url) => url === 'https://private.com/',
      );

      // Primary fetch fails
      mockGenerateContent.mockRejectedValueOnce(new Error('primary fail'));

      // Mock fallback fetch only for public URL
      mockFetch('https://public.com/', {
        text: () => Promise.resolve('public content'),
      });

      // Mock fallback LLM call
      mockGenerateContent.mockResolvedValueOnce({
        candidates: [{ content: { parts: [{ text: 'fallback response' }] } }],
      });

      const tool = new WebFetchTool(mockConfig, bus);
      const params = {
        prompt: 'fetch https://public.com/ and https://private.com',
      };
      const invocation = tool.build(params);
      const result = await invocation.execute({
        abortSignal: new AbortController().signal,
      });

      expect(result.llmContent).toBe(
        '<untrusted_context>\nfallback response\n</untrusted_context>',
      );
      // Verify private URL was NOT fetched (mockFetch would throw if it was called for private.com)
    });

    it('should return WEB_FETCH_FALLBACK_FAILED on total failure', async () => {
      vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
      mockGenerateContent.mockRejectedValue(new Error('primary fail'));
      mockFetch('https://public.ip/', new Error('fallback fetch failed'));
      const tool = new WebFetchTool(mockConfig, bus);
      const params = { prompt: 'fetch https://public.ip' };
      const invocation = tool.build(params);
      const result = await invocation.execute({
        abortSignal: new AbortController().signal,
      });
      expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_FALLBACK_FAILED);
    });

    it('should log telemetry when falling back due to primary fetch failure', async () => {
      vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
      // Mock primary fetch to return empty response, triggering fallback
      mockGenerateContent.mockResolvedValueOnce({
        candidates: [],
      });
      // Mock fetchWithTimeout to succeed so fallback proceeds
      mockFetch('https://public.ip/', {
        text: () => Promise.resolve('some content'),
      });
      // Mock fallback LLM call
      mockGenerateContent.mockResolvedValueOnce({
        candidates: [{ content: { parts: [{ text: 'fallback response' }] } }],
      });

      const tool = new WebFetchTool(mockConfig, bus);
      const params = { prompt: 'fetch https://public.ip' };
      const invocation = tool.build(params);
      await invocation.execute({ abortSignal: new AbortController().signal });

      expect(logWebFetchFallbackAttempt).toHaveBeenCalledWith(
        mockConfig,
        expect.objectContaining({ reason: 'primary_failed' }),
      );
      expect(WebFetchFallbackAttemptEvent).toHaveBeenCalledWith(
        'primary_failed',
      );
    });
  });

  describe('execute (fallback)', () => {
    beforeEach(() => {
      // Force fallback by mocking primary fetch to fail
      vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
      mockGenerateContent.mockResolvedValueOnce({
        candidates: [],
      });
    });

    it.each([
      {
        name: 'HTML content using html-to-text',
        content: '<html><body><h1>Hello</h1></body></html>',
        contentType: 'text/html; charset=utf-8',
        shouldConvert: true,
      },
      {
        name: 'raw text for JSON content',
        content: '{"key": "value"}',
        contentType: 'application/json',
        shouldConvert: false,
      },
      {
        name: 'raw text for plain text content',
        content: 'Just some text.',
        contentType: 'text/plain',
        shouldConvert: false,
      },
      {
        name: 'content with no Content-Type header as HTML',
        content: '<p>No header</p>',
        contentType: null,
        shouldConvert: true,
      },
    ])(
      'should handle $name',
      async ({ content, contentType, shouldConvert }) => {
        const headers = contentType
          ? new Headers({ 'content-type': contentType })
          : new Headers();

        mockFetch('https://example.com/', {
          headers,
          text: () => Promise.resolve(content),
        });

        // Mock fallback LLM call to return the content passed to it
        mockGenerateContent.mockImplementationOnce(async (_, req) => ({
          candidates: [
            { content: { parts: [{ text: req[0].parts[0].text }] } },
          ],
        }));

        const tool = new WebFetchTool(mockConfig, bus);
        const params = { prompt: 'fetch https://example.com' };
        const invocation = tool.build(params);
        const result = await invocation.execute({
          abortSignal: new AbortController().signal,
        });

        const sanitizeXml = (text: string) =>
          text
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&apos;');

        if (shouldConvert) {
          expect(convert).toHaveBeenCalledWith(content, {
            wordwrap: false,
            selectors: [
              { selector: 'a', options: { ignoreHref: true } },
              { selector: 'img', format: 'skip' },
            ],
          });
          expect(result.llmContent).toContain(
            `Converted: ${sanitizeXml(content)}`,
          );
        } else {
          expect(convert).not.toHaveBeenCalled();
          expect(result.llmContent).toContain(sanitizeXml(content));
        }
      },
    );
  });

  describe('shouldConfirmExecute', () => {
    it('should return confirmation details with the correct prompt and parsed urls', async () => {
      const tool = new WebFetchTool(mockConfig, bus);
      const params = { prompt: 'fetch https://example.com' };
      const invocation = tool.build(params);
      const confirmationDetails = await invocation.shouldConfirmExecute(
        new AbortController().signal,
      );

      expect(confirmationDetails).toEqual({
        type: 'info',
        title: 'Confirm Web Fetch',
        prompt: 'fetch https://example.com',
        urls: ['https://example.com/'],
        onConfirm: expect.any(Function),
      });
    });

    it('should handle URL param in confirmation details', async () => {
      vi.spyOn(mockConfig, 'getDirectWebFetch').mockReturnValue(true);
      const tool = new WebFetchTool(mockConfig, bus);
      const params = { url: 'https://example.com' };
      const invocation = tool.build(params);
      const confirmationDetails = await invocation.shouldConfirmExecute(
        new AbortController().signal,
      );

      expect(confirmationDetails).toEqual({
        type: 'info',
        title: 'Confirm Web Fetch',
        prompt: 'Fetch https://example.com',
        urls: ['https://example.com'],
        onConfirm: expect.any(Function),
      });
    });

    it('should convert github urls to raw format', async () => {
      const tool = new WebFetchTool(mockConfig, bus);
      const params = {
        prompt:
          'fetch https://github.com/google/gemini-react/blob/main/README.md',
      };
      const invocation = tool.build(params);
      const confirmationDetails = await invocation.shouldConfirmExecute(
        new AbortController().signal,
      );

      expect(confirmationDetails).toEqual({
        type: 'info',
        title: 'Confirm Web Fetch',
        prompt:
          'fetch https://github.com/google/gemini-react/blob/main/README.md',
        urls: [
          'https://raw.githubusercontent.com/google/gemini-react/main/README.md',
        ],
        onConfirm: expect.any(Function),
      });
    });

    it('should return false if approval mode is AUTO_EDIT', async () => {
      vi.spyOn(mockConfig, 'getApprovalMode').mockReturnValue(
        ApprovalMode.AUTO_EDIT,
      );
      const tool = new WebFetchTool(mockConfig, bus);
      const params = { prompt: 'fetch https://example.com' };
      const invocation = tool.build(params);
      const confirmationDetails = await invocation.shouldConfirmExecute(
        new AbortController().signal,
      );

      expect(confirmationDetails).toBe(false);
    });

    it('should NOT call setApprovalMode when onConfirm is called with ProceedAlways (now handled by scheduler)', async () => {
      const tool = new WebFetchTool(mockConfig, bus);
      const params = { prompt: 'fetch https://example.com' };
      const invocation = tool.build(params);
      const confirmationDetails = await invocation.shouldConfirmExecute(
        new AbortController().signal,
      );

      if (
        confirmationDetails &&
        typeof confirmationDetails === 'object' &&
        'onConfirm' in confirmationDetails
      ) {
        await confirmationDetails.onConfirm(
          ToolConfirmationOutcome.ProceedAlways,
        );
      }

      // Schedulers are now responsible for mode transitions via updatePolicy
      expect(mockConfig.setApprovalMode).not.toHaveBeenCalled();
    });
  });

  describe('getPolicyUpdateOptions', () => {
    it('should return empty object for any outcome to allow global approval', () => {
      const tool = new WebFetchTool(mockConfig, bus);
      const invocation = tool.build({ prompt: 'fetch https://example.com' });

      expect(
        invocation.getPolicyUpdateOptions!(
          ToolConfirmationOutcome.ProceedAlways,
        ),
      ).toEqual({});
      expect(
        invocation.getPolicyUpdateOptions!(
          ToolConfirmationOutcome.ProceedAlwaysAndSave,
        ),
      ).toEqual({});
    });
  });

  describe('Message Bus Integration', () => {
    let policyEngine: PolicyEngine;
    let messageBus: MessageBus;
    let mockUUID: Mock;

    const createToolWithMessageBus = (customBus?: MessageBus) => {
      const tool = new WebFetchTool(mockConfig, customBus ?? bus);
      const params = { prompt: 'fetch https://example.com' };
      return { tool, invocation: tool.build(params) };
    };

    const simulateMessageBusResponse = (
      subscribeSpy: ReturnType<typeof vi.spyOn>,
      confirmed: boolean,
      correlationId = 'test-correlation-id',
    ) => {
      const responseHandler = subscribeSpy.mock.calls[0][1] as (
        response: ToolConfirmationResponse,
      ) => void;
      const response: ToolConfirmationResponse = {
        type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
        correlationId,
        confirmed,
      };
      responseHandler(response);
    };

    beforeEach(() => {
      policyEngine = new PolicyEngine();
      messageBus = new MessageBus(policyEngine);
      mockUUID = vi.mocked(randomUUID);
      mockUUID.mockReturnValue('test-correlation-id');
    });

    it('should use message bus for confirmation when available', async () => {
      const { invocation } = createToolWithMessageBus(messageBus);
      const publishSpy = vi.spyOn(messageBus, 'publish');
      const subscribeSpy = vi.spyOn(messageBus, 'subscribe');
      const unsubscribeSpy = vi.spyOn(messageBus, 'unsubscribe');

      const confirmationPromise = invocation.shouldConfirmExecute(
        new AbortController().signal,
      );

      expect(publishSpy).toHaveBeenCalledWith({
        type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
        toolCall: {
          name: 'web_fetch',
          args: { prompt: 'fetch https://example.com' },
        },
        correlationId: 'test-correlation-id',
      });

      expect(subscribeSpy).toHaveBeenCalledWith(
        MessageBusType.TOOL_CONFIRMATION_RESPONSE,
        expect.any(Function),
      );

      simulateMessageBusResponse(subscribeSpy, true);

      const result = await confirmationPromise;
      expect(result).toBe(false);
      expect(unsubscribeSpy).toHaveBeenCalled();
    });

    it('should reject promise when confirmation is denied via message bus', async () => {
      const { invocation } = createToolWithMessageBus(messageBus);
      const subscribeSpy = vi.spyOn(messageBus, 'subscribe');

      const confirmationPromise = invocation.shouldConfirmExecute(
        new AbortController().signal,
      );

      simulateMessageBusResponse(subscribeSpy, false);

      await expect(confirmationPromise).rejects.toThrow(
        'Tool execution for "WebFetch" denied by policy.',
      );
    });

    it('should handle timeout gracefully', async () => {
      vi.useFakeTimers();
      const { invocation } = createToolWithMessageBus(messageBus);
      const confirmationPromise = invocation.shouldConfirmExecute(
        new AbortController().signal,
      );

      await vi.advanceTimersByTimeAsync(30000);
      const result = await confirmationPromise;
      expect(result).not.toBe(false);
      expect(result).toHaveProperty('type', 'info');

      vi.useRealTimers();
    });

    it('should handle abort signal during confirmation', async () => {
      const { invocation } = createToolWithMessageBus(messageBus);
      const abortController = new AbortController();
      const confirmationPromise = invocation.shouldConfirmExecute(
        abortController.signal,
      );

      abortController.abort();

      await expect(confirmationPromise).rejects.toThrow(
        'Tool execution for "WebFetch" denied by policy.',
      );
    });

    it('should ignore responses with wrong correlation ID', async () => {
      vi.useFakeTimers();
      const { invocation } = createToolWithMessageBus(messageBus);
      const subscribeSpy = vi.spyOn(messageBus, 'subscribe');
      const confirmationPromise = invocation.shouldConfirmExecute(
        new AbortController().signal,
      );

      simulateMessageBusResponse(subscribeSpy, true, 'wrong-id');

      await vi.advanceTimersByTimeAsync(30000);
      const result = await confirmationPromise;
      expect(result).not.toBe(false);
      expect(result).toHaveProperty('type', 'info');

      vi.useRealTimers();
    });

    it('should handle message bus publish errors gracefully', async () => {
      const { invocation } = createToolWithMessageBus(messageBus);
      vi.spyOn(messageBus, 'publish').mockImplementation(() => {
        throw new Error('Message bus error');
      });

      const result = await invocation.shouldConfirmExecute(
        new AbortController().signal,
      );
      expect(result).toBe(false);
    });

    it('should execute normally after confirmation approval', async () => {
      vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
      mockGenerateContent.mockResolvedValue({
        candidates: [
          {
            content: {
              parts: [{ text: 'Fetched content from https://example.com' }],
              role: 'model',
            },
          },
        ],
      });

      const { invocation } = createToolWithMessageBus(messageBus);
      const subscribeSpy = vi.spyOn(messageBus, 'subscribe');

      const confirmationPromise = invocation.shouldConfirmExecute(
        new AbortController().signal,
      );

      simulateMessageBusResponse(subscribeSpy, true);

      await confirmationPromise;

      const result = await invocation.execute({
        abortSignal: new AbortController().signal,
      });
      expect(result.error).toBeUndefined();
      expect(result.llmContent).toContain('Fetched content');
    });
  });

  describe('execute (experimental)', () => {
    beforeEach(() => {
      vi.spyOn(mockConfig, 'getDirectWebFetch').mockReturnValue(true);
      vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
    });

    it('should perform direct fetch and return text for plain text content', async () => {
      const content = 'Plain text content';
      mockFetch('https://example.com/', {
        status: 200,
        headers: new Headers({ 'content-type': 'text/plain' }),
        text: () => Promise.resolve(content),
      });

      const tool = new WebFetchTool(mockConfig, bus);
      const params = { url: 'https://example.com' };
      const invocation = tool.build(params);
      const result = await invocation.execute({
        abortSignal: new AbortController().signal,
      });

      expect(result.llmContent).toBe(
        `<untrusted_context>\n${content}\n</untrusted_context>`,
      );
      expect(result.returnDisplay).toContain('Fetched text/plain content');
      expect(fetchUtils.fetchWithTimeout).toHaveBeenCalledWith(
        'https://example.com/',
        expect.any(Number),
        expect.objectContaining({
          headers: expect.objectContaining({
            Accept: expect.stringContaining('text/plain'),
          }),
        }),
      );
    });

    it('should use html-to-text and preserve links for HTML content', async () => {
      const content =
        '<html><body><a href="https://link.com">Link</a></body></html>';
      mockFetch('https://example.com/', {
        status: 200,
        headers: new Headers({ 'content-type': 'text/html' }),
        text: () => Promise.resolve(content),
      });

      const tool = new WebFetchTool(mockConfig, bus);
      const params = { url: 'https://example.com' };
      const invocation = tool.build(params);
      await invocation.execute({ abortSignal: new AbortController().signal });

      expect(convert).toHaveBeenCalledWith(
        content,
        expect.objectContaining({
          selectors: [
            expect.objectContaining({
              selector: 'a',
              options: { ignoreHref: false, baseUrl: 'https://example.com/' },
            }),
          ],
        }),
      );
    });

    it('should return base64 for image content', async () => {
      const buffer = Buffer.from('fake-image-data');
      mockFetch('https://example.com/image.png', {
        status: 200,
        headers: new Headers({ 'content-type': 'image/png' }),
        arrayBuffer: () =>
          Promise.resolve(
            buffer.buffer.slice(
              buffer.byteOffset,
              buffer.byteOffset + buffer.byteLength,
            ),
          ),
      });

      const tool = new WebFetchTool(mockConfig, bus);
      const params = { url: 'https://example.com/image.png' };
      const invocation = tool.build(params);
      const result = await invocation.execute({
        abortSignal: new AbortController().signal,
      });

      expect(result.llmContent).toEqual({
        inlineData: {
          data: buffer.toString('base64'),
          mimeType: 'image/png',
        },
      });
    });

    it('should return raw response info for 4xx/5xx errors', async () => {
      const errorBody = 'Not Found';
      mockFetch('https://example.com/404', {
        status: 404,
        headers: new Headers({ 'x-test': 'val' }),
        text: () => Promise.resolve(errorBody),
      });

      const tool = new WebFetchTool(mockConfig, bus);
      const params = { url: 'https://example.com/404' };
      const invocation = tool.build(params);
      const result = await invocation.execute({
        abortSignal: new AbortController().signal,
      });

      expect(result.llmContent).toContain('Request failed with status 404');
      expect(result.llmContent).toContain('val');
      expect(result.llmContent).toContain(errorBody);
      expect(result.returnDisplay).toContain('Failed to fetch');
    });

    it('should throw error if Content-Length exceeds limit', async () => {
      mockFetch('https://example.com/large', {
        headers: new Headers({
          'content-length': (11 * 1024 * 1024).toString(),
        }),
      });

      const tool = new WebFetchTool(mockConfig, bus);
      const invocation = tool.build({ url: 'https://example.com/large' });
      const result = await invocation.execute({
        abortSignal: new AbortController().signal,
      });

      expect(result.llmContent).toContain('Error');
      expect(result.llmContent).toContain('exceeds size limit');
    });

    it('should throw error if stream exceeds limit', async () => {
      const large_chunk = new Uint8Array(11 * 1024 * 1024);
      mockFetch('https://example.com/large-stream', {
        body: {
          getReader: () => ({
            read: vi
              .fn()
              .mockResolvedValueOnce({ done: false, value: large_chunk })
              .mockResolvedValueOnce({ done: true }),
            releaseLock: vi.fn(),
            cancel: vi.fn().mockResolvedValue(undefined),
          }),
        } as unknown as ReadableStream,
      });

      const tool = new WebFetchTool(mockConfig, bus);
      const invocation = tool.build({
        url: 'https://example.com/large-stream',
      });
      const result = await invocation.execute({
        abortSignal: new AbortController().signal,
      });

      expect(result.llmContent).toContain('Error');
      expect(result.llmContent).toContain('exceeds size limit');
    });

    it('should return error if url is missing (experimental)', async () => {
      const tool = new WebFetchTool(mockConfig, bus);
      // Manually bypass build() validation to test executeExperimental safety check
      const invocation = tool['createInvocation']({}, bus);
      const result = await invocation.execute({
        abortSignal: new AbortController().signal,
      });

      expect(result.llmContent).toContain('Error: No URL provided.');
      expect(result.error?.type).toBe(ToolErrorType.INVALID_TOOL_PARAMS);
    });

    it('should return error if url is invalid (experimental)', async () => {
      const tool = new WebFetchTool(mockConfig, bus);
      // Manually bypass build() validation to test executeExperimental safety check
      const invocation = tool['createInvocation']({ url: 'not-a-url' }, bus);
      const result = await invocation.execute({
        abortSignal: new AbortController().signal,
      });

      expect(result.llmContent).toContain('Error: Invalid URL "not-a-url"');
      expect(result.error?.type).toBe(ToolErrorType.INVALID_TOOL_PARAMS);
    });

    it('should block private IP (experimental)', async () => {
      vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(true);
      const tool = new WebFetchTool(mockConfig, bus);
      const invocation = tool['createInvocation'](
        { url: 'http://localhost' },
        bus,
      );
      const result = await invocation.execute({
        abortSignal: new AbortController().signal,
      });

      expect(result.llmContent).toContain(
        'Error: Access to blocked or private host http://localhost/ is not allowed.',
      );
      expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_PROCESSING_ERROR);
    });

    it('should bypass truncation if isContextManagementEnabled is true', async () => {
      vi.spyOn(mockConfig, 'isContextManagementEnabled').mockReturnValue(true);
      const largeContent = 'a'.repeat(300000); // Larger than MAX_CONTENT_LENGTH (250000)
      mockFetch('https://example.com/large-text', {
        status: 200,
        headers: new Headers({ 'content-type': 'text/plain' }),
        text: () => Promise.resolve(largeContent),
      });

      const tool = new WebFetchTool(mockConfig, bus);
      const invocation = tool.build({ url: 'https://example.com/large-text' });
      const result = await invocation.execute({
        abortSignal: new AbortController().signal,
      });

      expect((result.llmContent as string).length).toBe(300041); // No truncation
    });

    it('should truncate if isContextManagementEnabled is false', async () => {
      vi.spyOn(mockConfig, 'isContextManagementEnabled').mockReturnValue(false);
      const largeContent = 'a'.repeat(300000); // Larger than MAX_CONTENT_LENGTH (250000)
      mockFetch('https://example.com/large-text2', {
        status: 200,
        headers: new Headers({ 'content-type': 'text/plain' }),
        text: () => Promise.resolve(largeContent),
      });

      const tool = new WebFetchTool(mockConfig, bus);
      const invocation = tool.build({ url: 'https://example.com/large-text2' });
      const result = await invocation.execute({
        abortSignal: new AbortController().signal,
      });

      expect((result.llmContent as string).length).toBeLessThan(300000);
      expect(result.llmContent).toContain(
        '[Content truncated due to size limit]',
      );
    });
  });
});
