import {log} from './logger';
import {exec} from 'teen_process';
import {waitForCondition} from 'asyncbox';
import {getVersion} from 'appium-xcode';
import type {XcodeVersion} from 'appium-xcode';
import path from 'node:path';
import {Simctl} from 'node-simctl';
import type {StringRecord} from '@appium/types';
// it's a hack needed to stub getDevices in tests
import * as utilsModule from './utils';

const DEFAULT_SIM_SHUTDOWN_TIMEOUT_MS = 30000;
export const SAFARI_STARTUP_TIMEOUT_MS = 25 * 1000;
export const MOBILE_SAFARI_BUNDLE_ID = 'com.apple.mobilesafari';
export const SIMULATOR_APP_NAME = 'Simulator.app';
export const MIN_SUPPORTED_XCODE_VERSION = 14;

export interface SimulatorInfoOptions {
  devicesSetPath?: string | null;
}

/**
 * @param timeout - Timeout in milliseconds (default: DEFAULT_SIM_SHUTDOWN_TIMEOUT_MS).
 * @returns Promise that resolves when all simulators are killed.
 */
export async function killAllSimulators(
  timeout: number = DEFAULT_SIM_SHUTDOWN_TIMEOUT_MS,
): Promise<void> {
  log.debug('Killing all iOS Simulators');
  const xcodeVersion = await getVersion(true);
  if (typeof xcodeVersion === 'string') {
    return;
  }
  const appName = path.parse(SIMULATOR_APP_NAME).name;
  const version = xcodeVersion as XcodeVersion;

  // later versions are slower to close
  timeout = timeout * (version.major >= 8 ? 2 : 1);

  try {
    await exec('xcrun', ['simctl', 'shutdown', version.major > 8 ? 'all' : 'booted'], {timeout});
  } catch {}

  const pids: string[] = [];
  try {
    const {stdout} = await exec('pgrep', ['-f', `${appName}.app/Contents/MacOS/`]);
    if (stdout.trim()) {
      pids.push(...stdout.trim().split(/\s+/));
    }
  } catch (e: any) {
    if (e.code === 1) {
      log.debug(`${appName} is not running. Continuing...`);
      return;
    }
    if (pids.length === 0) {
      log.warn(
        `pgrep error ${e.code} while detecting whether ${appName} is running. Trying to kill anyway.`,
      );
    }
  }
  if (pids.length > 0) {
    log.debug(`Killing processes: ${pids.join(', ')}`);
    try {
      await exec('kill', ['-9', ...pids.map((pid) => `${pid}`)]);
    } catch {}
  }

  log.debug(`Using pkill to kill application: ${appName}`);
  try {
    await pkill(appName, true);
  } catch {}

  // wait for all the devices to be shutdown before Continuing
  // but only print out the failed ones when they are actually fully failed
  let remainingDevices: string[] = [];
  async function allSimsAreDown(): Promise<boolean> {
    remainingDevices = [];
    const devicesRecord = await utilsModule.getDevices();
    const devices = Object.values(devicesRecord).flat();
    return devices.every((sim: any) => {
      const state = sim.state.toLowerCase();
      const done = ['shutdown', 'unavailable', 'disconnected'].includes(state);
      if (!done) {
        remainingDevices.push(
          `${sim.name} (${sim.sdk}, udid: ${sim.udid}) is still in state '${state}'`,
        );
      }
      return done;
    });
  }
  try {
    await waitForCondition(allSimsAreDown, {
      waitMs: timeout,
      intervalMs: 200,
    });
  } catch (err) {
    if (remainingDevices.length > 0) {
      log.warn(`The following devices are still not in the correct state after ${timeout} ms:`);
      for (const device of remainingDevices) {
        log.warn(`    ${device}`);
      }
    }
    throw err;
  }
}

/**
 * @param udid - The simulator UDID.
 * @param opts - Options including devicesSetPath.
 * @returns Promise that resolves to simulator info or undefined if not found.
 */
export async function getSimulatorInfo(
  udid: string,
  opts: SimulatorInfoOptions = {},
): Promise<any> {
  const {devicesSetPath} = opts;
  // see the README for github.com/appium/node-simctl for example output of getDevices()
  const devices = Object.values(await utilsModule.getDevices({devicesSetPath})).flat();
  return devices.find((sim: any) => sim.udid === udid);
}

/**
 * @param udid - The simulator UDID.
 * @returns Promise that resolves to true if simulator exists, false otherwise.
 */
export async function simExists(udid: string): Promise<boolean> {
  return !!(await getSimulatorInfo(udid));
}

/**
 * @returns Promise that resolves to the developer root path.
 */
export async function getDeveloperRoot(): Promise<string> {
  const {stdout} = await exec('xcode-select', ['-p']);
  return stdout.trim();
}

/**
 * Asserts that the Xcode version meets the minimum supported version requirement.
 *
 * @template V - The Xcode version type.
 * @param xcodeVersion - The Xcode version to check.
 * @returns The same Xcode version if it meets the requirement.
 * @throws {Error} If the Xcode version is below the minimum supported version.
 */
export function assertXcodeVersion<V extends XcodeVersion>(xcodeVersion: V): V {
  if (xcodeVersion.major < MIN_SUPPORTED_XCODE_VERSION) {
    throw new Error(
      `Tried to use an iOS simulator with xcode version ${xcodeVersion.versionString} but only Xcode version ` +
        `${MIN_SUPPORTED_XCODE_VERSION} and up are supported`,
    );
  }
  return xcodeVersion;
}

/**
 * @param simctlOpts - Optional simctl options
 * @returns Promise that resolves to a record of devices grouped by SDK version
 */
export async function getDevices(simctlOpts?: StringRecord): Promise<Record<string, any[]>> {
  return await new Simctl(simctlOpts).getDevices();
}

/**
 * Checks whether the given value is a plain object.
 */
export function isPlainObject(value: unknown): value is Record<string, any> {
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
    return false;
  }
  const proto = Object.getPrototypeOf(value);
  return proto === null || proto === Object.prototype;
}

/**
 * Escapes regexp control characters in a string.
 */
export function escapeRegExp(value: string): string {
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/**
 * @param appName - The application name to kill.
 * @param forceKill - Whether to force kill the process.
 * @returns Promise that resolves to 0 on success.
 */
async function pkill(appName: string, forceKill: boolean = false): Promise<number> {
  const args = forceKill ? ['-9'] : [];
  args.push('-x', appName);
  try {
    await exec('pkill', args);
    return 0;
  } catch (err: any) {
    // pgrep/pkill exit codes:
    // 0       One or more processes were matched.
    // 1       No processes were matched.
    // 2       Invalid options were specified on the command line.
    // 3       An internal error occurred.
    if (err.code !== undefined) {
      throw new Error(`Cannot forcefully terminate ${appName}. pkill error code: ${err.code}`);
    }
    log.error(`Received unexpected error while trying to kill ${appName}: ${err.message}`);
    throw err;
  }
}
