import path from 'node:path';

import type {AppiumLogger} from '@appium/types';
import {fs, tempDir, zip, util, timing} from 'appium/support';
import {asyncmap} from 'asyncbox';
import {Devicectl} from 'node-devicectl';

import {IPA_EXT} from '../commands/constants';
import {buildSafariPreferences, SAFARI_BUNDLE_ID, TimeoutError, withTimeout} from '../commands/helpers';
import type {XCUITestDriver, XCUITestDriverOpts} from '../driver';
import {log as defaultLogger} from '../logger';
import {isEmpty, isPlainObject} from '../utils';
import {AfcClient} from './afc-client';
import {AppTerminationClient} from './app-termination-client';
import {ConnectedDevicesClient} from './connected-devices-client';
import {InstallationProxyClient} from './installation-proxy-client';
import {LockdownClient} from './lockdown-client';
import {NotificationClient} from './notification-client';
import type {RemoteXPCFacade} from './remote-xpc';
import {ZipConduitClient} from './zip-conduit-client';

const DEFAULT_APP_INSTALLATION_TIMEOUT_MS = 8 * 60 * 1000;
export const IO_TIMEOUT_MS = 4 * 60 * 1000;
// Mobile devices use NAND memory modules for the storage,
// and the parallelism there is not as performant as on regular SSDs
export const MAX_IO_CHUNK_SIZE = 8;
const APPLICATION_INSTALLED_NOTIFICATION = 'com.apple.mobile.application_installed';
const APPLICATION_NOTIFICATION_TIMEOUT_MS = 30 * 1000;
const INSTALLATION_STAGING_DIR = 'PublicStaging';

export interface AfcTransferOptions {
  log?: AppiumLogger;
}

export interface PushFileOptions extends AfcTransferOptions {
  /** The maximum count of milliseconds to wait until file push is completed. Cannot be lower than 60000ms */
  timeoutMs?: number;
}

export interface PushFolderOptions extends AfcTransferOptions {
  /** The maximum timeout to wait until a single file is copied */
  timeoutMs?: number;
  /** Whether to push files in parallel. This usually gives better performance, but might sometimes be less stable. */
  enableParallelPush?: boolean;
}

export interface PullFileOptions extends AfcTransferOptions {}

export interface PullFolderOptions extends AfcTransferOptions {}

export interface RealDeviceInstallOptions {
  /** Application installation timeout in milliseconds */
  timeoutMs?: number;
}

export interface InstallOrUpgradeOptions {
  /** Install/upgrade timeout in milliseconds */
  timeout: number;
  /** Whether it is an app upgrade or a new install */
  isUpgrade: boolean;
}

export interface ManagementInstallOptions {
  /** Whether to skip app uninstall before installing it */
  skipUninstall?: boolean;
  /** App install timeout */
  timeout?: number;
  /** Whether to enforce the app uninstallation. e.g. fullReset, or enforceAppInstall is true */
  shouldEnforceUninstall?: boolean;
}

interface AfcTransferStats {
  fileCount?: number;
  folderCount?: number;
}

export class RealDevice {
  readonly udid: string;
  readonly devicectl: Devicectl;
  readonly driverOpts: XCUITestDriverOpts;
  private readonly _log: AppiumLogger;
  private remoteXPCFacade: RemoteXPCFacade | null = null;

  constructor(udid: string, driverOpts: XCUITestDriverOpts, logger?: AppiumLogger) {
    this.udid = udid;
    this.driverOpts = driverOpts;
    this._log = logger ?? defaultLogger;
    this.devicectl = new Devicectl(this.udid);
  }

  get log(): AppiumLogger {
    return this._log;
  }

  attachRemoteXPCFacade(facade: RemoteXPCFacade): void {
    this.remoteXPCFacade = facade;
  }

  async remove(bundleId: string): Promise<void> {
    const client = await InstallationProxyClient.create(this.udid, {
      facade: this.remoteXPCFacade,
      logger: this.log,
    });
    try {
      await client.uninstallApplication(bundleId);
    } finally {
      await client.close();
    }
  }

  async removeApp(bundleId: string): Promise<void> {
    await this.remove(bundleId);
  }

  async install(appPath: string, bundleId: string, opts: RealDeviceInstallOptions = {}): Promise<void> {
    const {timeoutMs = IO_TIMEOUT_MS} = opts;
    const timer = new timing.Timer().start();
    const useRemoteXPC = (await this.remoteXPCFacade?.determineAvailability()) ?? false;

    // first try with zip_conduit service for iOS/tvOS 18+ and IPA only
    // fall through to the AFC + installation_proxy path for other cases/zip_conduit failure
    if (useRemoteXPC && (await this.installViaZipConduit(appPath, timeoutMs))) {
      this.log.info(
        `The installation of '${bundleId}' succeeded after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`,
      );
      return;
    }

    const afcClient = await AfcClient.createForDevice(this.udid, {
      facade: this.remoteXPCFacade,
    });
    try {
      let bundlePathOnPhone: string;
      if ((await fs.stat(appPath)).isFile()) {
        // https://github.com/doronz88/pymobiledevice3/blob/6ff5001f5776e03b610363254e82d7fbcad4ef5f/pymobiledevice3/services/installation_proxy.py#L75
        bundlePathOnPhone = `/${path.basename(appPath)}`;
        await pushFile(afcClient, appPath, bundlePathOnPhone, {
          timeoutMs,
          log: this.log,
        });
      } else {
        bundlePathOnPhone = `${INSTALLATION_STAGING_DIR}/${bundleId}`;
        await pushFolder(afcClient, appPath, bundlePathOnPhone, {
          enableParallelPush: true,
          timeoutMs,
          log: this.log,
        });
      }
      await this.installOrUpgradeApplication(bundlePathOnPhone, {
        timeout: Math.max(timeoutMs - timer.getDuration().asMilliSeconds, 60000),
        isUpgrade: await this.isAppInstalled(bundleId),
      });
    } catch (err) {
      this.log.debug((err as Error).stack);
      let errMessage = `Cannot install the ${bundleId} application`;
      if (err instanceof TimeoutError) {
        errMessage += `. Consider increasing the value of 'appPushTimeout' capability (the current value equals to ${timeoutMs}ms)`;
      }
      errMessage += `. Original error: ${(err as Error).message}`;
      throw new Error(errMessage, {cause: err});
    } finally {
      await afcClient.close();
    }
    this.log.info(
      `The installation of '${bundleId}' succeeded after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`,
    );
  }

  async installOrUpgradeApplication(bundlePathOnPhone: string, opts: InstallOrUpgradeOptions): Promise<void> {
    const {isUpgrade, timeout} = opts;
    const notificationClient = await NotificationClient.create(this.udid, {
      facade: this.remoteXPCFacade,
      logger: this.log,
    });
    const installationClient = await InstallationProxyClient.create(this.udid, {
      facade: this.remoteXPCFacade,
      logger: this.log,
    });
    const appInstalledNotification = notificationClient.observeNotification(APPLICATION_INSTALLED_NOTIFICATION);
    const clientOptions = {PackageType: 'Developer'};
    try {
      if (isUpgrade) {
        this.log.debug(
          `An upgrade of the existing application is going to be performed. ` +
            `Will timeout in ${timeout.toFixed(0)} ms`,
        );
        await installationClient.upgradeApplication(bundlePathOnPhone, clientOptions, timeout);
      } else {
        this.log.debug(
          `A new application installation is going to be performed. ` + `Will timeout in ${timeout.toFixed(0)} ms`,
        );
        await installationClient.installApplication(bundlePathOnPhone, clientOptions, timeout);
      }
      try {
        await withTimeout(
          appInstalledNotification,
          APPLICATION_NOTIFICATION_TIMEOUT_MS,
          `Could not get the application installed notification within ` +
            `${APPLICATION_NOTIFICATION_TIMEOUT_MS}ms but we will continue`,
        );
      } catch (e) {
        this.log.warn((e as Error).message);
      }
    } finally {
      await installationClient.close();
      await notificationClient.close();
    }
  }

  /**
   * Alias for {@linkcode install}
   */
  async installApp(appPath: string, bundleId: string, opts: RealDeviceInstallOptions = {}): Promise<void> {
    return await this.install(appPath, bundleId, opts);
  }

  /**
   * Return an application object if test app has 'bundleid'.
   * The target bundleid can be User and System apps.
   *
   * @param bundleId The bundleId to ensure it is installed
   * @returns Returns True if the app is installed on the device under test.
   */
  async isAppInstalled(bundleId: string): Promise<boolean> {
    if (process.platform === 'darwin' && isPreferDevicectlEnabled()) {
      return (await this.devicectl.listApps(bundleId)).length > 0;
    }
    return Boolean(await this.fetchAppInfo(bundleId));
  }

  /**
   * Fetches various attributes, like bundle id, version, entitlements etc. of
   * an installed application.
   *
   * @param bundleId the bundle identifier of an app to check
   * @param returnAttributes If provided then
   * only fetches the requested attributes of the app into the resulting object.
   * Some apps may have too many attributes, so it makes sense to limit these
   * by default if you don't need all of them.
   * @returns Either app info as an object or undefined if the app is not found.
   */
  async fetchAppInfo(
    bundleId: string,
    returnAttributes: string | string[] = ['CFBundleIdentifier', 'CFBundleVersion'],
  ): Promise<Record<string, any> | undefined> {
    const client = await InstallationProxyClient.create(this.udid, {
      facade: this.remoteXPCFacade,
      logger: this.log,
    });
    try {
      return (
        await client.lookupApplications({
          bundleIds: bundleId,
          // https://github.com/appium/appium/issues/18753
          returnAttributes: Array.isArray(returnAttributes) ? returnAttributes : [returnAttributes],
        })
      )[bundleId];
    } finally {
      await client.close();
    }
  }

  /**
   * Terminates the application with the given bundle identifier on the real device.
   * Uses RemoteXPC DVT processControl when session access allows it; on connection/execution
   * error falls back to the legacy path (InstallationProxy + devicectl or instrument service).
   *
   * @param bundleId - Bundle identifier of the app to terminate
   * @returns `true` if the app was running and was terminated, `false` otherwise
   */
  async terminateApp(bundleId: string): Promise<boolean> {
    const platformVersion = this.driverOpts.platformVersion ?? (await this.getPlatformVersion());
    const terminationClient = new AppTerminationClient(
      this.udid,
      platformVersion,
      this.devicectl,
      this.log,
      this.remoteXPCFacade,
    );
    return await terminationClient.terminate(bundleId);
  }

  /**
   * Used to locate duplicate WebDriverAgent runner apps.
   *
   * @param bundleName The name of CFBundleName in Info.plist
   * @returns A list of User level apps' bundle ids which has
   * 'CFBundleName' attribute as 'bundleName'.
   */
  async getUserInstalledBundleIdsByBundleName(bundleName: string): Promise<string[]> {
    const client = await InstallationProxyClient.create(this.udid, {
      facade: this.remoteXPCFacade,
      logger: this.log,
    });
    try {
      const applications = await client.listApplications({
        applicationType: 'User',
        returnAttributes: ['CFBundleIdentifier', 'CFBundleName'],
      });
      return Object.entries(applications).reduce((acc: string[], [key, {CFBundleName}]) => {
        if (CFBundleName === bundleName) {
          acc.push(key);
        }
        return acc;
      }, []);
    } finally {
      await client.close();
    }
  }

  async getPlatformVersion(): Promise<string> {
    const lockdown = await LockdownClient.createForDevice(this.udid, {
      facade: this.remoteXPCFacade,
      logger: this.log,
    });
    try {
      return await lockdown.getOSVersion();
    } finally {
      await lockdown.close();
    }
  }

  async reset(opts: {bundleId?: string; fullReset?: boolean}): Promise<void> {
    const {bundleId, fullReset} = opts;
    if (!bundleId || !fullReset || bundleId === SAFARI_BUNDLE_ID) {
      // Safari cannot be removed as system app.
      // Safari process handling will be managed by WDA
      // with noReset, forceAppLaunch or shouldTerminateApp capabilities.
      return;
    }

    this.log.debug(`Reset: fullReset requested. Will try to uninstall the app '${bundleId}'.`);
    if (!(await this.isAppInstalled(bundleId))) {
      this.log.debug('Reset: app not installed. No need to uninstall');
      return;
    }

    try {
      await this.remove(bundleId);
    } catch (err) {
      this.log.error(`Reset: could not remove '${bundleId}' from device: ${(err as Error).message}`);
      throw err;
    }
    this.log.debug(`Reset: removed '${bundleId}'`);
  }

  /**
   * Attempt a streaming zip_conduit install of an `.ipa` over RemoteXPC.
   *
   * @param appPath - Local path to the app package
   * @param timeoutMs - Overall install timeout in milliseconds
   * @returns `true` when the app was installed via zip_conduit; `false` when the
   * package is not an eligible `.ipa`, the service is unavailable, or the
   * streamed install failed. A `false` result tells the caller to fall back to
   * the AFC upload + installation_proxy path.
   */
  private async installViaZipConduit(appPath: string, timeoutMs: number): Promise<boolean> {
    // zip_conduit only accepts .ipa archives. Unpacked .app bundles (and any
    // other non-.ipa package) must use the AFC + installation_proxy
    // path, so anything that is not a regular .ipa file is skipped here.
    if (!appPath.toLowerCase().endsWith(IPA_EXT) || !(await fs.stat(appPath)).isFile()) {
      return false;
    }

    let client: ZipConduitClient | null = null;
    try {
      client = await ZipConduitClient.create(this.udid, this.log, this.remoteXPCFacade);
      if (!client) {
        return false;
      }
      this.log.debug(`Installing '${path.basename(appPath)}' via streaming zip_conduit`);
      await client.install(appPath, {timeoutMs});
      return true;
    } catch (err) {
      this.log.warn(
        `Fast zip_conduit install of '${path.basename(appPath)}' failed; falling back to ` +
          `AFC upload + installation_proxy. Original error: ${(err as Error).message}`,
      );
      return false;
    } finally {
      await client?.close();
    }
  }
}

/**
 * Retrieve a file from a real device
 *
 * @param client AFC client instance
 * @param remotePath Relative path to the file on the device
 * @param opts Pull file options
 * @returns The file content as a buffer
 */
export async function pullFile(client: AfcClient, remotePath: string, opts: PullFileOptions = {}): Promise<Buffer> {
  const log = opts.log ?? defaultLogger;
  const timer = new timing.Timer().start();
  const buffer = await withTimeout(
    client.getFileContents(remotePath),
    IO_TIMEOUT_MS,
    `Timed out after ${IO_TIMEOUT_MS}ms while pulling file from '${remotePath}'`,
  );
  logAfcTransferPerformance(log, 'download', buffer.length, remotePath, timer);
  return buffer;
}

/**
 * Retrieve a folder from a real device
 *
 * @param client AFC client instance
 * @param remoteRootPath Relative path to the folder on the device
 * @param opts Pull folder options
 * @returns The folder content as a zipped base64-encoded buffer
 */
export async function pullFolder(
  client: AfcClient,
  remoteRootPath: string,
  opts: PullFolderOptions = {},
): Promise<Buffer> {
  const log = opts.log ?? defaultLogger;
  const timer = new timing.Timer().start();
  const tmpFolder = await tempDir.openDir();
  let totalBytes = 0;
  try {
    let localTopItem: string | null = null;
    let countFilesSuccess = 0;
    let countFolders = 0;

    await client.pull(remoteRootPath, tmpFolder, {
      recursive: true,
      overwrite: true,
      onEntry: async (_remotePath: string, localPath: string, isDirectory: boolean) => {
        if (!localTopItem || localPath.split(path.sep).length < localTopItem.split(path.sep).length) {
          localTopItem = localPath;
        }
        if (isDirectory) {
          ++countFolders;
        } else {
          ++countFilesSuccess;
          totalBytes += (await fs.stat(localPath)).size;
        }
      },
    });

    logAfcTransferPerformance(log, 'download', totalBytes, remoteRootPath, timer, {
      fileCount: countFilesSuccess,
      folderCount: countFolders,
    });
    return await zip.toInMemoryZip(localTopItem ? path.dirname(localTopItem) : tmpFolder, {
      encodeToBase64: true,
    });
  } finally {
    await fs.rimraf(tmpFolder);
  }
}

/**
 * Pushes a file to a real device
 *
 * @param client AFC client instance
 * @param localPathOrPayload Either full path to the source file
 * or a buffer payload to be written into the remote destination
 * @param remotePath Relative path to the file on the device. The remote
 * folder structure is created automatically if necessary.
 * @param opts Push file options
 */
export async function pushFile(
  client: AfcClient,
  localPathOrPayload: string | Buffer,
  remotePath: string,
  opts: PushFileOptions = {},
): Promise<void> {
  const {timeoutMs = IO_TIMEOUT_MS, log = defaultLogger} = opts;
  const timer = new timing.Timer().start();
  await remoteMkdirp(client, path.dirname(remotePath));

  const pushPromise = Buffer.isBuffer(localPathOrPayload)
    ? client.setFileContents(remotePath, localPathOrPayload)
    : client.writeFromStream(remotePath, fs.createReadStream(localPathOrPayload, {autoClose: true}));

  const actualTimeout = Math.max(timeoutMs, 60000);
  await withTimeout(
    pushPromise,
    actualTimeout,
    `Timed out after ${actualTimeout}ms while pushing file to '${remotePath}'`,
  );

  const fileSize = Buffer.isBuffer(localPathOrPayload)
    ? localPathOrPayload.length
    : (await fs.stat(localPathOrPayload)).size;
  logAfcTransferPerformance(log, 'upload', fileSize, remotePath, timer);
}

/**
 * Pushes a folder to a real device
 *
 * @param client AFC client instance
 * @param srcRootPath The full path to the source folder
 * @param dstRootPath The relative path to the destination folder. The folder
 * will be deleted if already exists.
 * @param opts Push folder options
 */
export async function pushFolder(
  client: AfcClient,
  srcRootPath: string,
  dstRootPath: string,
  opts: PushFolderOptions = {},
): Promise<void> {
  const {timeoutMs = IO_TIMEOUT_MS, enableParallelPush = false, log = defaultLogger} = opts;

  const timer = new timing.Timer().start();
  const allItems =
    /** @type {import('path-scurry').Path[]} */ /** @type {unknown} */ (
      await fs.glob('**', {
        cwd: srcRootPath,
        withFileTypes: true,
      })
    ) as any[];
  log.debug(`Successfully scanned the tree structure of '${srcRootPath}'`);
  // top-level folders go first
  const foldersToPush: string[] = allItems
    .filter((x) => x.isDirectory())
    .map((x) => x.relative())
    .sort((a, b) => a.split(path.sep).length - b.split(path.sep).length);
  // larger files go first
  const filesToPush: string[] = allItems
    .filter((x) => !x.isDirectory())
    .sort((a, b) => (b.size ?? 0) - (a.size ?? 0))
    .map((x) => x.relative());
  const totalBytes = allItems.filter((x) => !x.isDirectory()).reduce((sum, x) => sum + (x.size ?? 0), 0);
  log.debug(
    `Got ${util.pluralize('folder', foldersToPush.length, true)} and ` +
      `${util.pluralize('file', filesToPush.length, true)} to push`,
  );
  // Create the folder structure
  try {
    await client.deleteDirectory(dstRootPath);
  } catch {}

  // do not forget about the root folder
  await client.createDirectory(dstRootPath);
  for (const relativeFolderPath of foldersToPush) {
    let absoluteFolderPath = path.join(dstRootPath, relativeFolderPath);
    while (absoluteFolderPath.endsWith(path.sep)) {
      absoluteFolderPath = absoluteFolderPath.slice(0, -path.sep.length);
    }
    if (absoluteFolderPath) {
      await client.createDirectory(absoluteFolderPath);
    }
  }
  log.debug(
    `Successfully created the remote folder structure ` + `(${util.pluralize('item', foldersToPush.length + 1, true)})`,
  );

  const _pushFile = async (relativePath: string): Promise<void> => {
    const absoluteSourcePath = path.join(srcRootPath, relativePath);
    const readStream = fs.createReadStream(absoluteSourcePath, {autoClose: true});
    const absoluteDestinationPath = path.join(dstRootPath, relativePath);

    const pushPromise = client.writeFromStream(absoluteDestinationPath, readStream);
    const actualTimeout = Math.max(timeoutMs - timer.getDuration().asMilliSeconds, 60000);
    await withTimeout(
      pushPromise,
      actualTimeout,
      `Timed out after ${actualTimeout}ms while pushing '${relativePath}' to '${absoluteDestinationPath}'`,
    );
  };

  if (enableParallelPush) {
    log.debug(`Proceeding to parallel files push (max ${MAX_IO_CHUNK_SIZE} writers)`);
    await withTimeout(
      asyncmap(
        filesToPush,
        async (relativeFilePath) => {
          await _pushFile(relativeFilePath);
          const elapsedMs = timer.getDuration().asMilliSeconds;
          if (elapsedMs > timeoutMs) {
            throw new TimeoutError(`Timed out after ${elapsedMs} ms`);
          }
        },
        {concurrency: MAX_IO_CHUNK_SIZE},
      ),
      Math.max(timeoutMs - timer.getDuration().asMilliSeconds, 60000),
    );
  } else {
    log.debug(`Proceeding to serial files push`);
    for (const relativeFilePath of filesToPush) {
      await _pushFile(relativeFilePath);
      const elapsedMs = timer.getDuration().asMilliSeconds;
      if (elapsedMs > timeoutMs) {
        throw new TimeoutError(`Timed out after ${elapsedMs} ms`);
      }
    }
  }

  logAfcTransferPerformance(log, 'upload', totalBytes, dstRootPath, timer, {
    fileCount: filesToPush.length,
    folderCount: foldersToPush.length,
  });
}

/**
 * Get list of connected devices.

 * @param opts - Driver options; used to decide if tunnel registry is used.
 */
export async function getConnectedDevices(opts: XCUITestDriverOpts): Promise<string[]> {
  const client = await ConnectedDevicesClient.create(opts);
  return await client.getConnectedDevices();
}

/**
 * Install app to real device
 */
export async function installToRealDevice(
  this: XCUITestDriver,
  app: string,
  bundleId?: string,
  opts: ManagementInstallOptions = {},
): Promise<void> {
  const device = this.device as RealDevice;

  if (!device.udid || !app || !bundleId) {
    this.log.debug('No device id, app or bundle id, not installing to real device.');
    return;
  }

  const {skipUninstall, timeout = DEFAULT_APP_INSTALLATION_TIMEOUT_MS} = opts;

  if (!skipUninstall) {
    this.log.info(`Reset requested. Removing app with id '${bundleId}' from the device`);
    await device.remove(bundleId);
  }
  this.log.debug(`Installing '${app}' on the device with UUID '${device.udid}'`);

  try {
    await device.install(app, bundleId, {
      timeoutMs: timeout,
    });
    this.log.debug('The app has been installed successfully.');
  } catch (e) {
    // Want to clarify the device's application installation state in this situation.

    if (!skipUninstall || !(e as Error).message.includes('MismatchedApplicationIdentifierEntitlement')) {
      // Other error cases that could not be recoverable by here.
      // Exact error will be in the log.

      // We cannot recover 'ApplicationVerificationFailed' situation since this reason is clearly the app's provisioning profile was invalid.
      // [XCUITest] Error installing app '/path/to.app': Unexpected data: {"Error":"ApplicationVerificationFailed","ErrorDetail":-402620395,"ErrorDescription":"Failed to verify code signature of /path/to.app : 0xe8008015 (A valid provisioning profile for this executable was not found.)"}
      throw e;
    }

    // If the error was by below error case, we could recover the situation
    // by uninstalling the device's app bundle id explicitly regard less the app exists on the device or not (e.g. offload app).
    // [XCUITest] Error installing app '/path/to.app': Unexpected data: {"Error":"MismatchedApplicationIdentifierEntitlement","ErrorDescription":"Upgrade's application-identifier entitlement string (TEAM_ID.com.kazucocoa.example) does not match installed application's application-identifier string (ANOTHER_TEAM_ID.com.kazucocoa.example); rejecting upgrade."}
    this.log.info(
      `The application identified by '${bundleId}' cannot be installed because it might ` +
        `be already cached on the device, probably with a different signature. ` +
        `Will try to remove it and install a new copy. Original error: ${(e as Error).message}`,
    );
    await device.remove(bundleId);
    await device.install(app, bundleId, {
      timeoutMs: timeout,
    });
    this.log.debug('The app has been installed after one retrial.');
  }
}

/**
 * Run real device reset
 */
export async function runRealDeviceReset(this: XCUITestDriver): Promise<void> {
  if (!this.opts.noReset || this.opts.fullReset) {
    this.log.debug('Reset: running ios real device reset flow');
    if (!this.opts.noReset) {
      await (this.device as RealDevice).reset(this.opts);
    }
  } else {
    this.log.debug('Reset: fullReset not set. Leaving as is');
  }
}

/**
 * Configures Safari startup options based on the given session capabilities.
 *
 * !!! This method mutates driver options.
 *
 * @returns true if process arguments have been modified
 */
export function applySafariStartupArgs(this: XCUITestDriver): boolean {
  const prefs = buildSafariPreferences(this.opts);
  if (isEmpty(prefs)) {
    return false;
  }

  const args = Object.entries(prefs).flatMap(([key, value]) => [key.startsWith('-') ? key : `-${key}`, String(value)]);
  defaultLogger.debug(`Generated Safari command line arguments: ${args.join(' ')}`);
  const processArguments = this.opts.processArguments as {args: string[]} | undefined;
  if (processArguments && isPlainObject(processArguments)) {
    processArguments.args = [...(processArguments.args ?? []), ...args];
  } else {
    this.opts.processArguments = {args};
  }
  return true;
}

/**
 * Auto-detect device UDID
 */
export async function detectUdid(this: XCUITestDriver): Promise<string> {
  this.log.debug('Auto-detecting real device udid...');
  const udids = await getConnectedDevices(this.opts);
  if (isEmpty(udids)) {
    throw new Error('No real devices are connected to the host');
  }
  const udid = udids[udids.length - 1];
  if (udids.length > 1) {
    this.log.info(`Multiple devices found: ${udids.join(', ')}`);
    this.log.info(`Choosing '${udid}'. Consider settings the 'udid' capability if another device must be selected`);
  }
  this.log.debug(`Detected real device udid: '${udid}'`);
  return udid;
}

// #region Private Helper Functions

function logAfcTransferPerformance(
  log: AppiumLogger,
  direction: 'upload' | 'download',
  byteCount: number,
  remotePath: string,
  timer: timing.Timer,
  stats: AfcTransferStats = {},
): void {
  const elapsedMs = timer.getDuration().asMilliSeconds;
  const elapsedSec = elapsedMs / 1000;
  const preposition = direction === 'upload' ? 'to' : 'from';
  const {fileCount, folderCount} = stats;
  const itemSummary =
    fileCount !== undefined || folderCount !== undefined
      ? ` (${util.pluralize('file', fileCount ?? 0, true)}` +
        `${folderCount !== undefined ? ` and ${util.pluralize('folder', folderCount, true)}` : ''})`
      : '';

  log.debug(
    `AFC ${direction} of ${util.toReadableSizeString(byteCount)} ${preposition} '${remotePath}'` +
      `${itemSummary} completed in ${elapsedMs.toFixed(0)}ms`,
  );
  if (elapsedSec >= 1 && byteCount > 0) {
    const bytesPerSec = Math.floor(byteCount / elapsedSec);
    log.debug(`Approximate average AFC ${direction} speed: ${util.toReadableSizeString(bytesPerSec)}/s`);
  }
}

/**
 * If the environment variable enables APPIUM_XCUITEST_PREFER_DEVICECTL.
 * This is a workaround for wireless tvOS.
 * @returns True if the APPIUM_XCUITEST_PREFER_DEVICECTL is set.
 */
function isPreferDevicectlEnabled(): boolean {
  return ['yes', 'true', '1'].includes(String(process.env.APPIUM_XCUITEST_PREFER_DEVICECTL).toLowerCase());
}

/**
 * Creates remote folder path recursively. Noop if the given path
 * already exists
 *
 * @param client AFC client instance
 * @param remoteRoot The relative path to the remote folder structure
 * to be created
 */
async function remoteMkdirp(client: AfcClient, remoteRoot: string): Promise<void> {
  if (remoteRoot === '.' || remoteRoot === '/') {
    return;
  }

  try {
    await client.listDirectory(remoteRoot);
    return;
  } catch {
    // Directory is missing, create parent first
    await remoteMkdirp(client, path.dirname(remoteRoot));
  }

  await client.createDirectory(remoteRoot);
}

// #endregion Private Helper Functions
