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

import assert from 'node:assert';
import {spawn} from 'node:child_process';
import path from 'node:path';

import type {CallToolResult} from '@modelcontextprotocol/sdk/types.js';
import logger from 'debug';
import type {Browser} from 'puppeteer';
import puppeteer, {Locator} from 'puppeteer';
import type {
  Frame,
  HTTPRequest,
  HTTPResponse,
  LaunchOptions,
  Page,
  Target,
} from 'puppeteer-core';
import sinon from 'sinon';

import type {ParsedArguments} from '../src/bin/chrome-devtools-mcp-cli-options.js';
import {McpContext} from '../src/McpContext.js';
import {McpResponse} from '../src/McpResponse.js';
import {TextSnapshot} from '../src/TextSnapshot.js';
import {DevTools} from '../src/third_party/index.js';
import {stableIdSymbol} from '../src/utils/id.js';

export function assertNoServiceWorkerReported(targets: Target[], id: string) {
  const target = targets.find(target => {
    return target.url().includes(id) && target.type() === 'service_worker';
  });
  assert(target === undefined);
}

export function getTextContent(
  content: CallToolResult['content'][number],
): string {
  if (content.type === 'text') {
    return content.text;
  }
  throw new Error(`Expected text content but got ${content.type}`);
}

export function getImageContent(content: CallToolResult['content'][number]): {
  data: string;
  mimeType: string;
} {
  if (content.type === 'image') {
    return {data: content.data, mimeType: content.mimeType};
  }
  throw new Error(`Expected image content but got ${content.type}`);
}

export function extractExtensionId(response: McpResponse) {
  const responseLine = response.responseLines[0];
  assert.ok(responseLine, 'Response should not be empty');
  const match = responseLine.match(/Extension installed\. Id: (.+)/);
  const extensionId = match ? match[1] : null;
  assert.ok(extensionId, 'Response should contain a valid key');
  return extensionId;
}

const browsers = new Map<string, Browser>();
let context: McpContext | undefined;

export async function withBrowser(
  cb: (browser: Browser, page: Page) => Promise<void>,
  options: {
    debug?: boolean;
    autoOpenDevTools?: boolean;
    executablePath?: string;
    args?: string[];
    blockedUrlPattern?: string[];
    allowedUrlPattern?: string[];
  } = {},
) {
  const launchOptions: LaunchOptions = {
    executablePath:
      options.executablePath ?? process.env.PUPPETEER_EXECUTABLE_PATH,
    headless: !options.debug,
    defaultViewport: null,
    devtools: options.autoOpenDevTools ?? false,
    pipe: true,
    handleDevToolsAsPage: true,
    args: [...(options.args || []), '--screen-info={3840x2160}'],
    enableExtensions: true,
    blocklist: options.blockedUrlPattern,
    allowlist: options.allowedUrlPattern,
  };
  const key = JSON.stringify(launchOptions);

  let browser = browsers.get(key);
  if (!browser) {
    browser = await puppeteer.launch(launchOptions);
    browsers.set(key, browser);
  }
  const newPage = await browser.newPage();
  // Close other pages.
  await Promise.all(
    (await browser.pages()).map(async page => {
      if (page !== newPage) {
        await page.close();
      }
    }),
  );

  await cb(browser, newPage);
}

export async function withMcpContext(
  cb: (response: McpResponse, context: McpContext) => Promise<void>,
  options: {
    debug?: boolean;
    autoOpenDevTools?: boolean;
    performanceCrux?: boolean;
    executablePath?: string;
    args?: string[];
    blockedUrlPattern?: string[];
    allowedUrlPattern?: string[];
    allowUnrestrictedPaths?: boolean;
  } = {},
  args: Partial<ParsedArguments> = {},
) {
  await withBrowser(async browser => {
    TextSnapshot.resetCounter();
    const response = new McpResponse(args as ParsedArguments);
    if (context) {
      context.dispose();
    }
    context = await McpContext.from(
      browser,
      logger('test'),
      {
        experimentalDevToolsDebugging: false,
        performanceCrux: options.performanceCrux ?? true,
        allowList: options.allowedUrlPattern,
        blocklist: options.blockedUrlPattern,
        allowUnrestrictedPaths: options.allowUnrestrictedPaths ?? false,
      },
      Locator,
    );

    response.setPage(context.getSelectedMcpPage());

    await cb(response, context);
  }, options);
}

export function getMockRequest(
  options: {
    url?: string;
    method?: string;
    response?: HTTPResponse;
    failure?: HTTPRequest['failure'];
    resourceType?: string;
    hasPostData?: boolean;
    postData?: string;
    fetchPostData?: Promise<string>;
    stableId?: number;
    navigationRequest?: boolean;
    frame?: Frame;
    redirectChain?: HTTPRequest[];
    headers?: Record<string, string>;
  } = {},
): HTTPRequest {
  return {
    url() {
      return options.url ?? 'http://example.com';
    },
    method() {
      return options.method ?? 'GET';
    },
    fetchPostData() {
      return options.fetchPostData ?? Promise.reject();
    },
    hasPostData() {
      return options.hasPostData ?? false;
    },
    postData() {
      return options.postData;
    },
    response() {
      return options.response ?? null;
    },
    failure() {
      return options.failure?.() ?? null;
    },
    resourceType() {
      return options.resourceType ?? 'document';
    },
    headers(): Record<string, string> {
      return (
        options.headers ?? {
          'content-size': '10',
        }
      );
    },
    redirectChain(): HTTPRequest[] {
      // Puppeteer returns a fresh copy on every call (HTTPRequest returns
      // `this._redirectChain.slice()`); mirror that so formatters can't share
      // and accidentally mutate the same array across calls.
      return [...(options.redirectChain ?? [])];
    },
    isNavigationRequest() {
      return options.navigationRequest ?? false;
    },
    frame() {
      return options.frame ?? ({} as Frame);
    },
    [stableIdSymbol]: options.stableId ?? 1,
  } as unknown as HTTPRequest;
}

export function getMockResponse(
  options: {
    status?: number;
    headers?: Record<string, string>;
  } = {},
): HTTPResponse {
  return {
    status() {
      return options.status ?? 200;
    },
    headers(): Record<string, string> {
      return options.headers ?? {};
    },
  } as unknown as HTTPResponse;
}

export function html(
  strings: TemplateStringsArray,
  ...values: unknown[]
): string {
  const bodyContent = strings.reduce((acc, str, i) => {
    return acc + str + (values[i] || '');
  }, '');

  return `<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My test page</title>
  </head>
  <body>
    ${bodyContent}
  </body>
</html>`;
}

export function stabilizeStructuredContent(content: unknown): unknown {
  if (typeof content === 'string') {
    return stabilizeResponseOutput(content);
  }
  if (Array.isArray(content)) {
    return content.map(item => stabilizeStructuredContent(item));
  }
  if (typeof content === 'object' && content !== null) {
    const result: Record<string, unknown> = {};
    for (const [key, value] of Object.entries(content)) {
      if (key === 'snapshotFilePath' && typeof value === 'string') {
        result[key] = '<file>';
      } else {
        result[key] = stabilizeStructuredContent(value);
      }
    }
    return result;
  }
  return content;
}

export function stabilizeResponseOutput(text: unknown) {
  if (typeof text !== 'string') {
    throw new Error('Input must be string');
  }
  let output = text;
  const dateRegEx = /.{3}, \d{2} .{3} \d{4} \d{2}:\d{2}:\d{2} [A-Z]{3}/g;
  output = output.replaceAll(dateRegEx, '<long date>');

  const localhostRegEx = /localhost:\d{5}/g;
  output = output.replaceAll(localhostRegEx, 'localhost:<port>');

  const userAgentRegEx = /user-agent:.*\n/g;
  output = output.replaceAll(userAgentRegEx, 'user-agent:<user-agent>\n');

  const chUaRegEx = /sec-ch-ua:"Chromium";v="\d{3}"/g;
  output = output.replaceAll(chUaRegEx, 'sec-ch-ua:"Chromium";v="<version>"');

  // sec-ch-ua-platform:"Linux"
  const chUaPlatformRegEx = /sec-ch-ua-platform:"[a-zA-Z]*"/g;
  output = output.replaceAll(chUaPlatformRegEx, 'sec-ch-ua-platform:"<os>"');

  const savedSnapshot = /Saved snapshot to (.*)/g;
  output = output.replaceAll(savedSnapshot, 'Saved snapshot to <file>');

  const acceptLanguageRegEx = /accept-language:.*\n/g;
  output = output.replaceAll(acceptLanguageRegEx, 'accept-language:<lang>\n');

  // Stabilize URL-encoded file paths
  const fileUriRegEx = /file%3A%2F%2F%2F[^)\n]+/g;
  output = output.replaceAll(fileUriRegEx, '<file-path>');

  return output;
}

export function getMockAggregatedIssue(): sinon.SinonStubbedInstance<DevTools.AggregatedIssue> {
  const mockAggregatedIssue = sinon.createStubInstance(
    DevTools.AggregatedIssue,
  );
  mockAggregatedIssue.getAllIssues.returns([]);
  return mockAggregatedIssue;
}

export function mockListener() {
  const listeners: Record<string, Array<(data: unknown) => void>> = {};
  return {
    on(eventName: string, listener: (data: unknown) => void) {
      if (listeners[eventName]) {
        listeners[eventName].push(listener);
      } else {
        listeners[eventName] = [listener];
      }
    },
    off(_eventName: string, _listener: (data: unknown) => void) {
      // no-op
    },
    emit(eventName: string, data: unknown) {
      for (const listener of listeners[eventName] ?? []) {
        listener(data);
      }
    },
  };
}

export function getMockPage(): Page {
  const mainFrame = {} as Frame;
  const cdpSession = {
    ...mockListener(),
    send: () => {
      // no-op
    },
    target: () => ({_targetId: '<mock target ID>'}),
  };
  return {
    mainFrame() {
      return mainFrame;
    },
    ...mockListener(),
    // @ts-expect-error internal API.
    _client() {
      return cdpSession;
    },
  } satisfies Page;
}

export function getMockBrowser(): Browser {
  const pages = [getMockPage()];
  return {
    pages() {
      return Promise.resolve(pages);
    },
    ...mockListener(),
  } as Browser;
}

export const CLI_PATH = path.resolve('build/src/bin/chrome-devtools.js');

export async function runCli(
  args: string[],
  sessionId?: string,
): Promise<{status: number | null; stdout: string; stderr: string}> {
  return new Promise((resolve, reject) => {
    const finalArgs = [...args];
    if (sessionId) {
      finalArgs.push('--sessionId', sessionId);
    }
    const child = spawn('node', [CLI_PATH, ...finalArgs], {
      env: process.env,
    });
    let stdout = '';
    let stderr = '';
    child.stdout.on('data', chunk => {
      stdout += chunk;
      process.stdout.write(chunk);
    });
    child.stderr.on('data', chunk => {
      stderr += chunk;
      process.stderr.write(chunk);
    });
    child.on('close', status => resolve({status, stdout, stderr}));
    child.on('error', reject);
  });
}

export async function assertDaemonIsNotRunning(sessionId?: string) {
  const result = await runCli(['status'], sessionId);
  assert.strictEqual(
    result.stdout,
    'chrome-devtools-mcp daemon is not running.\n',
  );
}

export async function assertDaemonIsRunning(sessionId?: string) {
  const result = await runCli(['status'], sessionId);
  assert.ok(
    result.stdout.startsWith('chrome-devtools-mcp daemon is running.\n'),
    'chrome-devtools-mcp daemon is not running',
  );
}

export async function waitExecutionFor(
  func: () => Promise<void>,
  timeout: number,
) {
  const start = Date.now();
  while (Date.now() - start < 10000) {
    try {
      await func();
      return;
    } catch {
      await new Promise(resolve => setTimeout(resolve, 100)); // wait and retry
    }
  }

  throw new Error(`Timeout of ${timeout} reached.`);
}
