import {util, timing} from '@appium/support';
import axios from 'axios';
import net from 'node:net';
import {findAPortNotInUse} from 'portscanner';
import {sleep} from 'asyncbox';
import os from 'node:os';
import path from 'node:path';
import http from 'node:http';
import {Chromedriver} from 'appium-chromedriver';
import type {ChromedriverOpts} from 'appium-chromedriver';
import {toDetailsCacheKey, getWebviewDetails, WEBVIEWS_DETAILS_CACHE} from './cache';
import dns from 'node:dns/promises';
import type {AndroidDriver, AndroidDriverOpts} from '../../driver';
import type {
  GetWebviewsOpts,
  WebviewsMapping,
  WebviewProps,
  WebViewDetails,
  PortSpec,
  WebviewProc,
  DetailCollectionOptions,
} from '../types';
import type {StringRecord} from '@appium/types';

// https://cs.chromium.org/chromium/src/chrome/browser/devtools/device/android_device_info_query.cc
export const CHROME_BROWSER_PACKAGE_ACTIVITY = {
  chrome: {
    pkg: 'com.android.chrome',
    activity: 'com.google.android.apps.chrome.Main',
  },
  chromium: {
    pkg: 'org.chromium.chrome.shell',
    activity: '.ChromeShellActivity',
  },
  chromebeta: {
    pkg: 'com.chrome.beta',
    activity: 'com.google.android.apps.chrome.Main',
  },
  browser: {
    pkg: 'com.android.browser',
    activity: 'com.android.browser.BrowserActivity',
  },
  'chromium-browser': {
    pkg: 'org.chromium.chrome',
    activity: 'com.google.android.apps.chrome.Main',
  },
  'chromium-webview': {
    pkg: 'org.chromium.webview_shell',
    activity: 'org.chromium.webview_shell.WebViewBrowserActivity',
  },
  default: {
    pkg: 'com.android.chrome',
    activity: 'com.google.android.apps.chrome.Main',
  },
} as const;

export const CHROME_PACKAGE_NAME = 'com.android.chrome';
export const KNOWN_CHROME_PACKAGE_NAMES = [
  CHROME_PACKAGE_NAME,
  'com.chrome.beta',
  'com.chrome.dev',
  'com.chrome.canary',
] as const;
const CHROMEDRIVER_AUTODOWNLOAD_FEATURE = 'chromedriver_autodownload';
const CROSSWALK_SOCKET_PATTERN = /@(?!stetho_)([\w.]+)_devtools_remote\b/;
const CHROMIUM_DEVTOOLS_SOCKET = 'chrome_devtools_remote';
export const NATIVE_WIN = 'NATIVE_APP';
export const WEBVIEW_WIN = 'WEBVIEW';
export const CHROMIUM_WIN = 'CHROMIUM';
export const WEBVIEW_BASE = `${WEBVIEW_WIN}_`;
export const DEVTOOLS_SOCKET_PATTERN = /@[\w.]+_devtools_remote_?([\w.]+_)?(\d+)?\b/;
const WEBVIEW_PID_PATTERN = new RegExp(`^${WEBVIEW_BASE}(\\d+)`);
const WEBVIEW_PKG_PATTERN = new RegExp(`^${WEBVIEW_BASE}([^\\d\\s][\\w.]*)`);
const WEBVIEW_WAIT_INTERVAL_MS = 200;
const CDP_REQ_TIMEOUT = 2000; // ms
const DEVTOOLS_PORTS_RANGE = [10900, 11000] as const;
const DEVTOOLS_PORT_ALLOCATION_GUARD = util.getLockFileGuard(
  path.resolve(os.tmpdir(), 'android_devtools_port_guard'),
  {timeout: 7, tryRecovery: true},
);

// #region Exported Functions

/**
 * Gets the Chrome browser package and activity information for the specified browser name.
 *
 * @param browser - The browser name (e.g., 'chrome', 'chromium', 'chromebeta')
 * @returns The package and activity information for the browser, or the default Chrome configuration
 */
export function getChromePkg(
  browser: string,
): (typeof CHROME_BROWSER_PACKAGE_ACTIVITY)[keyof typeof CHROME_BROWSER_PACKAGE_ACTIVITY] {
  return (
    CHROME_BROWSER_PACKAGE_ACTIVITY[
      browser.toLowerCase() as keyof typeof CHROME_BROWSER_PACKAGE_ACTIVITY
    ] || CHROME_BROWSER_PACKAGE_ACTIVITY.default
  );
}

/**
 * Parses webview names from the webviews mapping for use in getContexts.
 * Filters out webviews that don't have pages if ensureWebviewsHavePages is enabled.
 *
 * @param webviewsMapping - Array of webview mapping objects
 * @param options - Options including ensureWebviewsHavePages and isChromeSession flags
 * @returns An array of webview context names
 */
export function parseWebviewNames(
  this: AndroidDriver,
  webviewsMapping: WebviewsMapping[],
  options: GetWebviewsOpts & {isChromeSession?: boolean} = {},
): string[] {
  const {ensureWebviewsHavePages = true, isChromeSession = false} = options;
  if (isChromeSession) {
    return [CHROMIUM_WIN];
  }

  const result: string[] = [];
  for (const {webview, pages, proc, webviewName} of webviewsMapping) {
    if (ensureWebviewsHavePages && !pages?.length) {
      this.log.info(
        `Skipping the webview '${webview}' at '${proc}' ` +
          `since it has reported having zero pages`,
      );
      continue;
    }
    if (webviewName) {
      result.push(webviewName);
    }
  }
  this.log.debug(
    `Found ${util.pluralize('webview', result.length, true)}: ${JSON.stringify(result)}`,
  );
  return result;
}

/**
 * Get a list of available webviews mapping by introspecting processes with adb,
 * where webviews are listed. It's possible to pass in a 'deviceSocket' arg, which
 * limits the webview possibilities to the one running on the Chromium devtools
 * socket we're interested in (see note on webviewsFromProcs). We can also
 * direct this method to verify whether a particular webview process actually
 * has any pages (if a process exists but no pages are found, Chromedriver will
 * not actually be able to connect to it, so this serves as a guard for that
 * strange failure mode). The strategy for checking whether any pages are
 * active involves sending a request to the remote debug server on the device,
 * hence it is also possible to specify the port on the host machine which
 * should be used for this communication.
 *
 * @param opts - Options for webview discovery including device socket, port, and collection settings
 * @returns An array of webview mapping objects
 */
export async function getWebViewsMapping(
  this: AndroidDriver,
  opts: GetWebviewsOpts = {},
): Promise<WebviewsMapping[]> {
  const {
    androidDeviceSocket = null,
    ensureWebviewsHavePages = true,
    webviewDevtoolsPort = null,
    enableWebviewDetailsCollection = true,
    waitForWebviewMs = 0,
  } = opts;

  this.log.debug(`Getting a list of available webviews`);

  let waitMs = waitForWebviewMs;
  if (!Number.isFinite(waitMs) || typeof waitMs !== 'number') {
    waitMs = parseInt(`${waitMs}`, 10) || 0;
  }

  let webviewsMapping: WebviewsMapping[];
  const timer = new timing.Timer().start();
  do {
    webviewsMapping = await webviewsFromProcs.bind(this)(androidDeviceSocket);

    if (webviewsMapping.length > 0) {
      break;
    }

    this.log.debug(`No webviews found in ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`);
    await sleep(WEBVIEW_WAIT_INTERVAL_MS);
  } while (timer.getDuration().asMilliSeconds < waitMs);

  await collectWebviewsDetails.bind(this)(webviewsMapping, {
    ensureWebviewsHavePages,
    enableWebviewDetailsCollection,
    webviewDevtoolsPort,
  });

  for (const webviewMapping of webviewsMapping) {
    const {webview, info} = webviewMapping;
    webviewMapping.webviewName = null;

    let wvName = webview;
    let process: {name: string; id: string | null} | undefined;
    if (!androidDeviceSocket) {
      const pkgMatch = WEBVIEW_PKG_PATTERN.exec(webview);
      try {
        // web view name could either be suffixed with PID or the package name
        // package names could not start with a digit
        const pkg = pkgMatch ? pkgMatch[1] : await procFromWebview.bind(this)(webview);
        wvName = `${WEBVIEW_BASE}${pkg}`;
        const pidMatch = WEBVIEW_PID_PATTERN.exec(webview);
        process = {
          name: pkg,
          id: pidMatch ? pidMatch[1] : null,
        };
      } catch (e) {
        const err = e as Error;
        this.log.debug(err.stack);
        this.log.warn(err.message);
        continue;
      }
    }

    webviewMapping.webviewName = wvName;
    const key = toDetailsCacheKey(this.adb, wvName);
    if (info || process) {
      WEBVIEWS_DETAILS_CACHE.set(key, {info, process});
    } else if (WEBVIEWS_DETAILS_CACHE.has(key)) {
      WEBVIEWS_DETAILS_CACHE.delete(key);
    }
  }
  return webviewsMapping;
}

/**
 * Sets up a new Chromedriver instance for the specified context.
 *
 * @param opts - Driver options with Chrome-specific settings
 * @param curDeviceId - The current device ID
 * @param context - Optional context name for webview sessions
 * @returns A configured Chromedriver instance
 */
export async function setupNewChromedriver(
  this: AndroidDriver,
  opts: AndroidDriverOpts & Record<string, any>,
  curDeviceId: string,
  context?: string,
): Promise<Chromedriver> {
  // TODO: Remove the legacy
  if ((opts as any).chromeDriverPort) {
    this.log.warn(
      `The 'chromeDriverPort' capability is deprecated. Please use 'chromedriverPort' instead`,
    );
    opts.chromedriverPort = (opts as any).chromeDriverPort;
  }

  if (opts.chromedriverPort) {
    this.log.debug(`Using user-specified port ${opts.chromedriverPort} for chromedriver`);
  } else {
    // if a single port wasn't given, we'll look for a free one
    opts.chromedriverPort = await getChromedriverPort.bind(this)(opts.chromedriverPorts);
  }

  const details = context ? getWebviewDetails(this.adb, context) : undefined;
  if (!util.isEmpty(details)) {
    this.log.debug(
      'Passing web view details to the Chromedriver constructor: ' +
        JSON.stringify(details, null, 2),
    );
  }

  const chromedriverOpts: ChromedriverOpts & {details?: WebViewDetails} = {
    port: opts.chromedriverPort == null ? undefined : String(opts.chromedriverPort),
    executable: opts.chromedriverExecutable,
    adb: this.adb,
    cmdArgs: opts.chromedriverArgs as string[] | undefined,
    verbose: !!opts.showChromedriverLog,
    executableDir: opts.chromedriverExecutableDir,
    mappingPath: opts.chromedriverChromeMappingFile,
    bundleId: (opts as any).chromeBundleId,
    useSystemExecutable: opts.chromedriverUseSystemExecutable,
    disableBuildCheck: opts.chromedriverDisableBuildCheck,
    details: details as any,
    isAutodownloadEnabled: isChromedriverAutodownloadEnabled.bind(this)(),
  };
  if (this.basePath) {
    chromedriverOpts.reqBasePath = this.basePath;
  }
  const chromedriver = new Chromedriver(chromedriverOpts);
  // make sure there are chromeOptions
  opts.chromeOptions = opts.chromeOptions || {};
  // try out any prefixed chromeOptions,
  // and strip the prefix
  for (const opt of Object.keys(opts)) {
    if (opt.endsWith(':chromeOptions')) {
      this?.log?.warn(`Merging '${opt}' into 'chromeOptions'. This may cause unexpected behavior`);
      merge(opts.chromeOptions, (opts as Record<string, any>)[opt]);
    }
  }

  // Ensure there are logging preferences
  opts.chromeLoggingPrefs = opts.chromeLoggingPrefs ?? {};

  // Strip the prefix and store it
  for (const opt of Object.keys(opts)) {
    if (opt.endsWith(':loggingPrefs')) {
      this.log.warn(
        `Merging '${opt}' into 'chromeLoggingPrefs'. This may cause unexpected behavior`,
      );
      merge(opts.chromeLoggingPrefs, (opts as Record<string, any>)[opt]);
    }
  }

  const caps = createChromedriverCaps.bind(this)(opts, curDeviceId, details) as any;
  this.log.debug(
    `Before starting chromedriver, androidPackage is '${caps.chromeOptions.androidPackage}'`,
  );
  // Optionally pre-grant all runtime permissions to the resolved Chrome package before Chrome
  // is launched, so the session is not interrupted by a native permission dialog (geolocation,
  // camera, file access, ...). Because the grant is requested explicitly, failures propagate.
  if (opts.chromedriverGrantPermissions) {
    const chromePkg = caps.chromeOptions?.androidPackage;
    if (!chromePkg) {
      throw new Error(
        `'chromedriverGrantPermissions' is enabled but the Chrome package name could not be ` +
          `resolved from the chromedriver capabilities, so runtime permissions cannot be granted`,
      );
    }
    await this.adb.grantAllPermissions(chromePkg);
  }
  const sessionCaps = await chromedriver.start(caps);
  cacheChromedriverCaps.bind(this)(sessionCaps, context);
  return chromedriver;
}

/**
 * Sets up an existing Chromedriver instance, checking if it's still working.
 * If not, restarts the session.
 *
 * @param chromedriver - The existing Chromedriver instance
 * @param context - The context name associated with this Chromedriver
 * @returns The Chromedriver instance (possibly restarted)
 */
export async function setupExistingChromedriver<T extends Chromedriver>(
  this: AndroidDriver,
  chromedriver: T,
  context: string,
): Promise<T> {
  // check the status by sending a simple window-based command to ChromeDriver
  // if there is an error, we want to recreate the ChromeDriver session
  if (await chromedriver.hasWorkingWebview()) {
    const cachedCaps = this._chromedriverCapsCache.get(context);
    if (cachedCaps) {
      cacheChromedriverCaps.bind(this)(cachedCaps, context);
    }
  } else {
    this.log.debug('ChromeDriver is not associated with a window. Re-initializing the session.');
    const sessionCaps = await chromedriver.restart();
    cacheChromedriverCaps.bind(this)(sessionCaps, context);
  }
  return chromedriver;
}

/**
 * Determines if the Chrome welcome dialog should be dismissed based on Chrome options.
 *
 * @returns True if the '--no-first-run' argument is present in chromeOptions
 */
export function shouldDismissChromeWelcome(this: AndroidDriver): boolean {
  return (
    !!this.opts.chromeOptions &&
    Array.isArray(this.opts.chromeOptions.args) &&
    this.opts.chromeOptions.args.includes('--no-first-run')
  );
}

/**
 * Dismisses the Chrome welcome dialog if it appears.
 * Handles both the terms acceptance and sign-in dialog.
 */
export async function dismissChromeWelcome(this: AndroidDriver): Promise<void> {
  this.log.info('Trying to dismiss Chrome welcome');
  const activity = await this.getCurrentActivity();
  if (activity !== 'org.chromium.chrome.browser.firstrun.FirstRunActivity') {
    this.log.info('Chrome welcome dialog never showed up! Continuing');
    return;
  }
  const el = await this.findElOrEls('id', 'com.android.chrome:id/terms_accept', false);
  await this.click((el as any).ELEMENT as string);
  try {
    const el2 = await this.findElOrEls('id', 'com.android.chrome:id/negative_button', false);
    await this.click((el2 as any).ELEMENT as string);
  } catch (e) {
    // DO NOTHING, THIS DEVICE DIDNT LAUNCH THE SIGNIN DIALOG
    // IT MUST BE A NON GMS DEVICE
    this.log.warn(`This device did not show Chrome SignIn dialog, ${(e as Error).message}`);
  }
}

// #endregion
// #region Internal Helper Functions

async function getFreePort(): Promise<number> {
  return await new Promise((resolve, reject) => {
    const srv = net.createServer();
    srv.listen(0, () => {
      const address = srv.address();
      let port: number;
      if (address && typeof address === 'object' && 'port' in address) {
        port = address.port;
      } else {
        reject(new Error('Cannot determine any free port number'));
        return;
      }
      srv.close(() => resolve(port));
    });
  });
}

/**
 * https://chromedevtools.github.io/devtools-protocol/
 */
async function cdpGetRequest(host: string, port: number, endpoint: string): Promise<any[]> {
  // Workaround for https://github.com/puppeteer/puppeteer/issues/2242, https://github.com/appium/appium/issues/20782
  const compatibleHost = isCompatibleCdpHost(host) ? host : (await dns.lookup(host)).address;
  return (
    await axios({
      url: `http://${compatibleHost}:${port}${endpoint}`,
      timeout: CDP_REQ_TIMEOUT,
      // We need to set this from Node.js v19 onwards.
      // Otherwise, in situation with multiple webviews,
      // the preceding webview pages will be incorrectly retrieved as the current ones.
      // https://nodejs.org/en/blog/announcements/v19-release-announce#https11-keepalive-by-default
      httpAgent: new http.Agent({keepAlive: false}),
    })
  ).data;
}

/**
 */
async function cdpList(host: string, port: number): Promise<any[]> {
  return cdpGetRequest(host, port, '/json/list');
}

/**
 */
async function cdpInfo(host: string, port: number): Promise<any> {
  return cdpGetRequest(host, port, '/json/version');
}

/**
 * Creates Chromedriver capabilities based on the provided Appium capabilities.
 */
function createChromedriverCaps(
  this: AndroidDriver,
  opts: any,
  deviceId: string,
  webViewDetails?: WebViewDetails | null,
): StringRecord {
  const caps: any = {chromeOptions: {}};

  const androidPackage =
    opts.chromeOptions?.androidPackage ||
    opts.appPackage ||
    webViewDetails?.info?.['Android-Package'];
  if (androidPackage) {
    // chromedriver raises an invalid argument error when androidPackage is 'null'

    caps.chromeOptions.androidPackage = androidPackage;
  }
  if (typeof opts.chromeUseRunningApp === 'boolean') {
    caps.chromeOptions.androidUseRunningApp = opts.chromeUseRunningApp;
  }
  if (opts.chromeAndroidPackage) {
    caps.chromeOptions.androidPackage = opts.chromeAndroidPackage;
  }
  if (opts.chromeAndroidActivity) {
    caps.chromeOptions.androidActivity = opts.chromeAndroidActivity;
  }
  if (opts.chromeAndroidProcess) {
    caps.chromeOptions.androidProcess = opts.chromeAndroidProcess;
  } else if (webViewDetails?.process?.name && webViewDetails?.process?.id) {
    caps.chromeOptions.androidProcess = webViewDetails.process.name;
  }
  if (String(opts.browserName)?.toLowerCase() === 'chromium-webview') {
    caps.chromeOptions.androidActivity = opts.appActivity;
  }
  if (opts.pageLoadStrategy) {
    caps.pageLoadStrategy = opts.pageLoadStrategy;
  }
  const isChrome = String(caps.chromeOptions.androidPackage)?.toLowerCase() === 'chrome';
  if (KNOWN_CHROME_PACKAGE_NAMES?.includes(caps.chromeOptions.androidPackage as any) || isChrome) {
    // if we have extracted package from context name, it could come in as bare
    // "chrome", and so we should make sure the details are correct, including
    // not using an activity or process id
    if (isChrome) {
      caps.chromeOptions.androidPackage = CHROME_PACKAGE_NAME;
    }
    delete caps.chromeOptions.androidActivity;
    delete caps.chromeOptions.androidProcess;
  }
  // add device id from adb
  caps.chromeOptions.androidDeviceSerial = deviceId;

  if (util.isPlainObject(opts.loggingPrefs) || util.isPlainObject(opts.chromeLoggingPrefs)) {
    if (opts.loggingPrefs) {
      this.log.warn(
        `The 'loggingPrefs' cap is deprecated; use the 'chromeLoggingPrefs' cap instead`,
      );
    }
    caps.loggingPrefs = opts.chromeLoggingPrefs || opts.loggingPrefs;
  }
  if (opts.enablePerformanceLogging) {
    this.log.warn(
      `The 'enablePerformanceLogging' cap is deprecated; simply use ` +
        `the 'chromeLoggingPrefs' cap instead, with a 'performance' key set to 'ALL'`,
    );
    const newPref = {performance: 'ALL'};
    // don't overwrite other logging prefs that have been sent in if they exist
    caps.loggingPrefs = caps.loggingPrefs ? {...caps.loggingPrefs, ...newPref} : newPref;
  }

  if (opts.chromeOptions?.Arguments) {
    // merge `Arguments` and `args`
    opts.chromeOptions.args = [...(opts.chromeOptions.args || []), ...opts.chromeOptions.Arguments];
    delete opts.chromeOptions.Arguments;
  }

  if (opts.webSocketUrl && caps.webSocketUrl == null && this.opts.chromedriverForwardBiDi) {
    caps.webSocketUrl = opts.webSocketUrl;
  }

  this.log.debug(
    'Precalculated Chromedriver capabilities: ' + JSON.stringify(caps.chromeOptions, null, 2),
  );

  const protectedCapNames: string[] = [];
  for (const [opt, val] of Object.entries(opts.chromeOptions)) {
    if (caps.chromeOptions[opt] === undefined) {
      caps.chromeOptions[opt] = val;
    } else {
      protectedCapNames.push(opt);
    }
  }
  if (!util.isEmpty(protectedCapNames)) {
    this.log.info(
      'The following Chromedriver capabilities cannot be overridden ' +
        'by the provided chromeOptions:',
    );
    for (const optName of protectedCapNames) {
      this.log.info(`  ${optName} (${JSON.stringify(opts.chromeOptions[optName])})`);
    }
  }

  return caps;
}

/**
 * Allocates a local port for devtools communication.
 *
 * @param socketName - The remote Unix socket name
 * @param webviewDevtoolsPort - The local port number or null to apply autodetection
 * @returns The host name and the port number to connect to if the remote socket has been forwarded successfully
 * @throws {Error} If there was an error while allocating the local port
 */
async function allocateDevtoolsChannel(
  this: AndroidDriver,
  socketName: string,
  webviewDevtoolsPort: number | null = null,
): Promise<[string, number]> {
  // socket names come with '@', but this should not be a part of the abstract
  // remote port, so remove it
  const remotePort = socketName.replace(/^@/, '');
  let startPort: number = DEVTOOLS_PORTS_RANGE[0];
  let endPort: number = DEVTOOLS_PORTS_RANGE[1];
  if (webviewDevtoolsPort) {
    endPort = webviewDevtoolsPort + (endPort - startPort);
    startPort = webviewDevtoolsPort;
  }
  this.log.debug(
    `Forwarding remote port ${remotePort} to a local ` + `port in range ${startPort}..${endPort}`,
  );
  if (!webviewDevtoolsPort) {
    this.log.debug(
      `You could use the 'webviewDevtoolsPort' capability to customize ` +
        `the starting port number`,
    );
  }
  const port = await DEVTOOLS_PORT_ALLOCATION_GUARD(async () => {
    let localPort: number;
    try {
      localPort = await findAPortNotInUse(startPort, endPort);
    } catch {
      throw new Error(
        `Cannot find any free port to forward the Devtools socket ` +
          `in range ${startPort}..${endPort}. You could set the starting port number ` +
          `manually by providing the 'webviewDevtoolsPort' capability`,
      );
    }
    await this.adb.adbExec(['forward', `tcp:${localPort}`, `localabstract:${remotePort}`]);
    return localPort as number;
  });
  return [this.adb.adbHost ?? '127.0.0.1', port as number];
}

/**
 * Wrapper for Chrome Debugger Protocol data collection.
 * No error is thrown if CDP request fails - in such case no data will be
 * recorded into the corresponding `webviewsMapping` item.
 *
 * @param webviewsMapping - The current webviews mapping. Each item of this array gets mutated
 *                          (`info`/`pages` properties get added based on the provided `opts`)
 *                          if the requested details have been successfully retrieved for it
 * @param opts - If both `ensureWebviewsHavePages` and `enableWebviewDetailsCollection`
 *               properties are falsy then no details collection is performed
 */
async function collectWebviewsDetails(
  this: AndroidDriver,
  webviewsMapping: WebviewProps[],
  opts: DetailCollectionOptions = {},
): Promise<void> {
  if (util.isEmpty(webviewsMapping)) {
    return;
  }

  const {
    webviewDevtoolsPort = null,
    ensureWebviewsHavePages = null,
    enableWebviewDetailsCollection = null,
  } = opts;

  if (!ensureWebviewsHavePages) {
    this.log.info(
      `Not checking whether webviews have active pages; use the ` +
        `'ensureWebviewsHavePages' cap to turn this check on`,
    );
  }

  if (!enableWebviewDetailsCollection) {
    this.log.info(
      `Not collecting web view details. Details collection might help ` +
        `to make Chromedriver initialization more precise. Use the 'enableWebviewDetailsCollection' ` +
        `cap to turn it on`,
    );
  }

  if (!ensureWebviewsHavePages && !enableWebviewDetailsCollection) {
    return;
  }

  // Connect to each devtools socket and retrieve web view details
  this.log.debug(
    `Collecting CDP data of ${util.pluralize('webview', webviewsMapping.length, true)}`,
  );
  const detailCollectors: Promise<void>[] = [];
  for (const item of webviewsMapping) {
    detailCollectors.push(
      (async (): Promise<void> => {
        let port: number | undefined;
        let host: string | undefined;
        try {
          [host, port] = (await allocateDevtoolsChannel.bind(this)(
            item.proc,
            webviewDevtoolsPort,
          )) as [string, number];
          if (enableWebviewDetailsCollection) {
            item.info = await cdpInfo(host, port);
          }
          if (ensureWebviewsHavePages) {
            item.pages = await cdpList(host, port);
          }
        } catch (e) {
          const err = e as Error;
          this.log.info(
            `CDP data for '${item.webview}' cannot be collected. Original error: ${err.message}`,
          );
        } finally {
          if (port) {
            try {
              await this.adb.removePortForward(port);
            } catch (e) {
              this.log.debug(e);
            }
          }
        }
      })(),
    );
  }
  await Promise.all(detailCollectors);
  this.log.debug(`CDP data collection completed`);
}

/**
 * Takes a webview name like WEBVIEW_4296 and uses 'adb shell ps' to figure out
 * which app package is associated with that webview.
 */
async function procFromWebview(this: AndroidDriver, webview: string): Promise<string> {
  const pidMatch = WEBVIEW_PID_PATTERN.exec(webview);
  if (!pidMatch) {
    throw new Error(`Could not find PID for webview '${webview}'`);
  }

  const pid = pidMatch[1];
  this.log.debug(`${webview} mapped to pid ${pid}`);
  this.log.debug(`Getting process name for webview '${webview}'`);
  const pkg = await this.adb.getProcessNameById(pid);
  this.log.debug(`Got process name: '${pkg}'`);
  return pkg;
}

/**
 * Gets a list of Android system processes that look like webviews.
 * See https://cs.chromium.org/chromium/src/chrome/browser/devtools/device/android_device_info_query.cc
 * for more details.
 */
async function getPotentialWebviewProcs(this: AndroidDriver): Promise<string[]> {
  const out = await this.adb.shell(['cat', '/proc/net/unix']);
  const names: string[] = [];
  const allMatches: string[] = [];
  for (const line of out.split('\n')) {
    // Num RefCount Protocol Flags Type St Inode Path
    const [, , , flags, , st, , sockPath] = line.trim().split(/\s+/);
    if (!sockPath) {
      continue;
    }
    if (sockPath.startsWith('@')) {
      allMatches.push(line.trim());
    }
    if (flags !== '00010000' || st !== '01') {
      continue;
    }
    if (!DEVTOOLS_SOCKET_PATTERN.test(sockPath)) {
      continue;
    }

    names.push(sockPath);
  }
  if (util.isEmpty(names)) {
    this.log.debug('Found no active devtools sockets');
    if (!util.isEmpty(allMatches)) {
      this.log.debug(`Other sockets are: ${JSON.stringify(allMatches, null, 2)}`);
    }
  } else {
    this.log.debug(
      `Parsed ${names.length} active devtools ${util.pluralize('socket', names.length, false)}: ` +
        JSON.stringify(names),
    );
  }
  // sometimes the webview process shows up multiple times per app
  return util.uniq(names);
}

/**
 * Retrieves a list of system processes that look like webviews,
 * and returns them along with the webview context name appropriate for it.
 * If a deviceSocket is provided, only attempts to find webviews which match
 * that socket name (for apps which embed Chromium, which isn't the
 * same as chrome-backed webviews).
 *
 * @param deviceSocket - The explicitly-named device socket to use, or null to find all webviews
 * @returns An array of webview process objects with proc and webview properties
 */
async function webviewsFromProcs(
  this: AndroidDriver,
  deviceSocket: string | null = null,
): Promise<WebviewProc[]> {
  const socketNames = await getPotentialWebviewProcs.bind(this)();
  const webviews: WebviewProc[] = [];
  for (const socketName of socketNames) {
    if (deviceSocket === CHROMIUM_DEVTOOLS_SOCKET && socketName === `@${deviceSocket}`) {
      webviews.push({
        proc: socketName,
        webview: CHROMIUM_WIN,
      });
      continue;
    }

    const socketNameMatch = DEVTOOLS_SOCKET_PATTERN.exec(socketName);
    if (!socketNameMatch) {
      continue;
    }
    const matchedSocketName = socketNameMatch[2];
    const crosswalkMatch = CROSSWALK_SOCKET_PATTERN.exec(socketName);
    if (!matchedSocketName && !crosswalkMatch) {
      continue;
    }

    if ((deviceSocket && socketName === `@${deviceSocket}`) || !deviceSocket) {
      webviews.push({
        proc: socketName,
        webview: matchedSocketName
          ? `${WEBVIEW_BASE}${matchedSocketName}`
          : `${WEBVIEW_BASE}${crosswalkMatch?.[1] ?? ''}`,
      });
    }
  }
  return webviews;
}

/**
 * Finds a free port for Chromedriver based on the provided port specification.
 * If no specification is provided, finds any available free port.
 */
async function getChromedriverPort(this: AndroidDriver, portSpec?: PortSpec): Promise<number> {
  // if the user didn't give us any specific information about chromedriver
  // port ranges, just find any free port
  if (!portSpec) {
    const port = await getFreePort();
    this.log.debug(`A port was not given, using random free port: ${port}`);
    return port;
  }

  // otherwise find the free port based on a list or range provided by the user
  this.log.debug(`Finding a free port for chromedriver using spec ${JSON.stringify(portSpec)}`);
  let foundPort: number | null = null;
  for (const potentialPort of portSpec) {
    let port: number;
    let stopPort: number;
    if (Array.isArray(potentialPort)) {
      [port, stopPort] = potentialPort.map((p) => parseInt(String(p), 10));
    } else {
      port = parseInt(String(potentialPort), 10); // ensure we have a number and not a string
      stopPort = port;
    }
    this.log.debug(`Checking port range ${port}:${stopPort}`);
    try {
      foundPort = await findAPortNotInUse(port, stopPort);
      break;
    } catch {
      this.log.debug(`Nothing in port range ${port}:${stopPort} was available`);
    }
  }

  if (foundPort === null) {
    throw new Error(
      `Could not find a free port for chromedriver using ` +
        `chromedriverPorts spec ${JSON.stringify(portSpec)}`,
    );
  }

  this.log.debug(`Using free port ${foundPort} for chromedriver`);
  return foundPort;
}

function isChromedriverAutodownloadEnabled(this: AndroidDriver): boolean {
  if (this.isFeatureEnabled(CHROMEDRIVER_AUTODOWNLOAD_FEATURE)) {
    return true;
  }
  this.log.debug(
    `Automated Chromedriver download is disabled. ` +
      `Use '${CHROMEDRIVER_AUTODOWNLOAD_FEATURE}' server feature to enable it`,
  );
  return false;
}

function cacheChromedriverCaps(
  this: AndroidDriver,
  sessionCaps: Record<string, any>,
  context?: string,
): void {
  if (!context) {
    return;
  }
  // Store full session capabilities in cache
  this._chromedriverCapsCache.set(context, sessionCaps);

  if (
    this.opts?.chromedriverForwardBiDi &&
    sessionCaps?.webSocketUrl &&
    this._bidiProxyUrl !== sessionCaps.webSocketUrl
  ) {
    this._bidiProxyUrl = sessionCaps.webSocketUrl;
    this.log.debug(`Updated Bidi Proxy URL to ${this._bidiProxyUrl}`);
  }
}

/**
 * https://github.com/puppeteer/puppeteer/issues/2242#issuecomment-544219536
 */
function isCompatibleCdpHost(host: string): boolean {
  return (
    ['localhost', 'localhost.localdomain'].includes(host) ||
    host.endsWith('.localhost') ||
    Boolean(net.isIP(host))
  );
}

function merge<T extends Record<string, any>>(target: T, source: any): T {
  if (!util.isPlainObject(source)) {
    return target;
  }

  for (const key of Object.keys(source)) {
    // Prevent prototype pollution when merging untrusted capability objects
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
      continue;
    }

    const sourceValue = (source as Record<string, any>)[key];

    if (util.isPlainObject(sourceValue)) {
      if (!util.isPlainObject((target as Record<string, any>)[key])) {
        (target as Record<string, any>)[key] = {};
      }
      merge((target as any)[key], sourceValue);
    } else {
      (target as Record<string, any>)[key] = sourceValue;
    }
  }

  return target;
}

// #endregion
