// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:meta/meta.dart';

import '../base/common.dart';
import '../base/logger.dart';
import '../base/platform.dart';
import '../base/terminal.dart';
import '../base/user_messages.dart';
import '../device.dart';
import '../globals.dart' as globals;
import '../ios/devices.dart';

const String _checkingForWirelessDevicesMessage = 'Checking for wireless devices...';
const String _chooseOneMessage = 'Please choose one (or "q" to quit)';
const String _connectedDevicesMessage = 'Connected devices:';
const String _foundButUnsupportedDevicesMessage = 'The following devices were found, but are not supported by this project:';
const String _noAttachedCheckForWirelessMessage = 'No devices found yet. Checking for wireless devices...';
const String _noDevicesFoundMessage = 'No devices found.';
const String _noWirelessDevicesFoundMessage = 'No wireless devices were found.';
const String _wirelesslyConnectedDevicesMessage = 'Wirelessly connected devices:';

String _chooseDeviceOptionMessage(int option, String name, String deviceId) => '[$option]: $name ($deviceId)';
String _foundMultipleSpecifiedDevicesMessage(String deviceId) =>
    'Found multiple devices with name or id matching $deviceId:';
String _foundSpecifiedDevicesMessage(int count, String deviceId) =>
    'Found $count devices with name or id matching $deviceId:';
String _noMatchingDeviceMessage(String deviceId) => 'No supported devices found with name or id '
    "matching '$deviceId'.";

/// This class handles functionality of finding and selecting target devices.
///
/// Target devices are devices that are supported and selectable to run
/// a flutter application on.
class TargetDevices {
  factory TargetDevices({
    required Platform platform,
    required DeviceManager deviceManager,
    required Logger logger,
    DeviceConnectionInterface? deviceConnectionInterface,
  }) {
    if (platform.isMacOS) {
      return TargetDevicesWithExtendedWirelessDeviceDiscovery(
        deviceManager: deviceManager,
        logger: logger,
        deviceConnectionInterface: deviceConnectionInterface,
      );
    }
    return TargetDevices._private(
      deviceManager: deviceManager,
      logger: logger,
      deviceConnectionInterface: deviceConnectionInterface,
    );
  }

  TargetDevices._private({
    required DeviceManager deviceManager,
    required Logger logger,
    required this.deviceConnectionInterface,
  })  : _deviceManager = deviceManager,
        _logger = logger;

  final DeviceManager _deviceManager;
  final Logger _logger;
  final DeviceConnectionInterface? deviceConnectionInterface;

  bool get _includeAttachedDevices =>
      deviceConnectionInterface == null ||
      deviceConnectionInterface == DeviceConnectionInterface.attached;
  bool get _includeWirelessDevices =>
      deviceConnectionInterface == null ||
      deviceConnectionInterface == DeviceConnectionInterface.wireless;

  Future<List<Device>> _getAttachedDevices({
    DeviceDiscoverySupportFilter? supportFilter,
  }) async {
    if (!_includeAttachedDevices) {
      return <Device>[];
    }
    return _deviceManager.getDevices(
      filter: DeviceDiscoveryFilter(
        deviceConnectionInterface: DeviceConnectionInterface.attached,
        supportFilter: supportFilter,
      ),
    );
  }

  Future<List<Device>> _getWirelessDevices({
    DeviceDiscoverySupportFilter? supportFilter,
  }) async {
    if (!_includeWirelessDevices) {
      return <Device>[];
    }
    return _deviceManager.getDevices(
      filter: DeviceDiscoveryFilter(
        deviceConnectionInterface: DeviceConnectionInterface.wireless,
        supportFilter: supportFilter,
      ),
    );
  }

  Future<List<Device>> _getDeviceById({
    bool includeDevicesUnsupportedByProject = false,
    bool includeDisconnected = false,
  }) async {
    return _deviceManager.getDevices(
      filter: DeviceDiscoveryFilter(
        excludeDisconnected: !includeDisconnected,
        supportFilter: _deviceManager.deviceSupportFilter(
          includeDevicesUnsupportedByProject: includeDevicesUnsupportedByProject,
        ),
        deviceConnectionInterface: deviceConnectionInterface,
      ),
    );
  }

  DeviceDiscoverySupportFilter _defaultSupportFilter(
    bool includeDevicesUnsupportedByProject,
  ) {
    return _deviceManager.deviceSupportFilter(
      includeDevicesUnsupportedByProject: includeDevicesUnsupportedByProject,
    );
  }

  void startExtendedWirelessDeviceDiscovery({
    Duration? deviceDiscoveryTimeout,
  }) {}

  /// Find and return all target [Device]s based upon criteria entered by the
  /// user on the command line.
  ///
  /// When the user has specified `all` devices, return all devices meeting criteria.
  ///
  /// When the user has specified a device id/name, attempt to find an exact or
  /// partial match. If an exact match or a single partial match is found,
  /// return it immediately.
  ///
  /// When multiple devices are found and there is a terminal attached to
  /// stdin, allow the user to select which device to use. When a terminal
  /// with stdin is not available, print a list of available devices and
  /// return null.
  ///
  /// When no devices meet user specifications, print a list of unsupported
  /// devices and return null.
  Future<List<Device>?> findAllTargetDevices({
    Duration? deviceDiscoveryTimeout,
    bool includeDevicesUnsupportedByProject = false,
  }) async {
    if (!globals.doctor!.canLaunchAnything) {
      _logger.printError(userMessages.flutterNoDevelopmentDevice);
      return null;
    }

    if (deviceDiscoveryTimeout != null) {
      // Reset the cache with the specified timeout.
      await _deviceManager.refreshAllDevices(timeout: deviceDiscoveryTimeout);
    }

    if (_deviceManager.hasSpecifiedDeviceId) {
      // Must check for device match separately from `_getAttachedDevices` and
      // `_getWirelessDevices` because if an exact match is found in one
      // and a partial match is found in another, there is no way to distinguish
      // between them.
      final List<Device> devices = await _getDeviceById(
        includeDevicesUnsupportedByProject: includeDevicesUnsupportedByProject,
      );
      if (devices.length == 1) {
        return devices;
      }
    }

    final List<Device> attachedDevices = await _getAttachedDevices(
      supportFilter: _defaultSupportFilter(includeDevicesUnsupportedByProject),
    );
    final List<Device> wirelessDevices = await _getWirelessDevices(
      supportFilter: _defaultSupportFilter(includeDevicesUnsupportedByProject),
    );
    final List<Device> allDevices = attachedDevices + wirelessDevices;

    if (allDevices.isEmpty) {
      return _handleNoDevices();
    } else if (_deviceManager.hasSpecifiedAllDevices) {
      return allDevices;
    } else if (allDevices.length > 1) {
      return _handleMultipleDevices(attachedDevices, wirelessDevices);
    }
    return allDevices;
  }

  /// When no supported devices are found, display a message and list of
  /// unsupported devices found.
  Future<List<Device>?> _handleNoDevices() async {
    // Get connected devices from cache, including unsupported ones.
    final List<Device> unsupportedDevices = await _deviceManager.getAllDevices(
      filter: DeviceDiscoveryFilter(
        deviceConnectionInterface: deviceConnectionInterface,
      )
    );

    if (_deviceManager.hasSpecifiedDeviceId) {
      _logger.printStatus(
        _noMatchingDeviceMessage(_deviceManager.specifiedDeviceId!),
      );
      if (unsupportedDevices.isNotEmpty) {
        _logger.printStatus('');
        _logger.printStatus('The following devices were found:');
        await Device.printDevices(unsupportedDevices, _logger);
      }
      return null;
    }

    _logger.printStatus(_deviceManager.hasSpecifiedAllDevices
        ? _noDevicesFoundMessage
        : userMessages.flutterNoSupportedDevices);
    await _printUnsupportedDevice(unsupportedDevices);
    return null;
  }

  /// Determine which device to use when multiple found.
  ///
  /// If user has not specified a device id/name, attempt to prioritize
  /// ephemeral devices. If a single ephemeral device is found, return it
  /// immediately.
  ///
  /// Otherwise, prompt the user to select a device if there is a terminal
  /// with stdin. If there is not a terminal, display the list of devices with
  /// instructions to use a device selection flag.
  Future<List<Device>?> _handleMultipleDevices(
    List<Device> attachedDevices,
    List<Device> wirelessDevices,
  ) async {
    final List<Device> allDevices = attachedDevices + wirelessDevices;

    final Device? ephemeralDevice = _deviceManager.getSingleEphemeralDevice(allDevices);
    if (ephemeralDevice != null) {
      return <Device>[ephemeralDevice];
    }

    if (globals.terminal.stdinHasTerminal) {
      return _selectFromMultipleDevices(attachedDevices, wirelessDevices);
    } else {
      return _printMultipleDevices(attachedDevices, wirelessDevices);
    }
  }

  /// Display a list of found devices. When the user has not specified the
  /// device id/name, display devices unsupported by the project as well and
  /// give instructions to use a device selection flag.
  Future<List<Device>?> _printMultipleDevices(
    List<Device> attachedDevices,
    List<Device> wirelessDevices,
  ) async {
    List<Device> supportedAttachedDevices = attachedDevices;
    List<Device> supportedWirelessDevices = wirelessDevices;
    if (_deviceManager.hasSpecifiedDeviceId) {
      final int allDeviceLength = supportedAttachedDevices.length + supportedWirelessDevices.length;
      _logger.printStatus(_foundSpecifiedDevicesMessage(
        allDeviceLength,
        _deviceManager.specifiedDeviceId!,
      ));
    } else {
      // Get connected devices from cache, including ones unsupported for the
      // project but still supported by Flutter.
      supportedAttachedDevices = await _getAttachedDevices(
        supportFilter: DeviceDiscoverySupportFilter.excludeDevicesUnsupportedByFlutter(),
      );
      supportedWirelessDevices = await _getWirelessDevices(
        supportFilter: DeviceDiscoverySupportFilter.excludeDevicesUnsupportedByFlutter(),
      );

      _logger.printStatus(userMessages.flutterSpecifyDeviceWithAllOption);
      _logger.printStatus('');
    }

    await Device.printDevices(supportedAttachedDevices, _logger);

    if (supportedWirelessDevices.isNotEmpty) {
      if (_deviceManager.hasSpecifiedDeviceId || supportedAttachedDevices.isNotEmpty) {
        _logger.printStatus('');
      }
      _logger.printStatus(_wirelesslyConnectedDevicesMessage);
      await Device.printDevices(supportedWirelessDevices, _logger);
    }

    return null;
  }

  /// Display a list of selectable devices, prompt the user to choose one, and
  /// wait for the user to select a valid option.
  Future<List<Device>?> _selectFromMultipleDevices(
    List<Device> attachedDevices,
    List<Device> wirelessDevices,
  ) async {
    final List<Device> allDevices = attachedDevices + wirelessDevices;

    if (_deviceManager.hasSpecifiedDeviceId) {
      _logger.printStatus(_foundSpecifiedDevicesMessage(
        allDevices.length,
        _deviceManager.specifiedDeviceId!,
      ));
    } else {
      _logger.printStatus(_connectedDevicesMessage);
    }

    await Device.printDevices(attachedDevices, _logger);

    if (wirelessDevices.isNotEmpty) {
      _logger.printStatus('');
      _logger.printStatus(_wirelesslyConnectedDevicesMessage);
      await Device.printDevices(wirelessDevices, _logger);
      _logger.printStatus('');
    }

    final Device chosenDevice = await _chooseOneOfAvailableDevices(allDevices);

    // Update the [DeviceManager.specifiedDeviceId] so that the user will not
    // be prompted again.
    _deviceManager.specifiedDeviceId = chosenDevice.id;

    return <Device>[chosenDevice];
  }

  Future<void> _printUnsupportedDevice(List<Device> unsupportedDevices) async {
    if (unsupportedDevices.isNotEmpty) {
      final StringBuffer result = StringBuffer();
      result.writeln();
      result.writeln(_foundButUnsupportedDevicesMessage);
      result.writeAll(
        (await Device.descriptions(unsupportedDevices))
            .map((String desc) => desc)
            .toList(),
        '\n',
      );
      result.writeln();
      result.writeln(userMessages.flutterMissPlatformProjects(
        Device.devicesPlatformTypes(unsupportedDevices),
      ));
      _logger.printStatus(result.toString(), newline: false);
    }
  }

  Future<Device> _chooseOneOfAvailableDevices(List<Device> devices) async {
    _displayDeviceOptions(devices);
    final String userInput =  await _readUserInput(devices.length);
    if (userInput.toLowerCase() == 'q') {
      throwToolExit('');
    }
    return devices[int.parse(userInput) - 1];
  }

  void _displayDeviceOptions(List<Device> devices) {
    int count = 1;
    for (final Device device in devices) {
      _logger.printStatus(_chooseDeviceOptionMessage(count, device.name, device.id));
      count++;
    }
  }

  Future<String> _readUserInput(int deviceCount) async {
    globals.terminal.usesTerminalUi = true;
    final String result = await globals.terminal.promptForCharInput(
      <String>[ for (int i = 0; i < deviceCount; i++) '${i + 1}', 'q', 'Q'],
      displayAcceptedCharacters: false,
      logger: _logger,
      prompt: _chooseOneMessage,
    );
    return result;
  }
}

@visibleForTesting
class TargetDevicesWithExtendedWirelessDeviceDiscovery extends TargetDevices {
  TargetDevicesWithExtendedWirelessDeviceDiscovery({
    required super.deviceManager,
    required super.logger,
    super.deviceConnectionInterface,
  })  : super._private();

  Future<void>? _wirelessDevicesRefresh;

  @visibleForTesting
  bool waitForWirelessBeforeInput = false;

  @visibleForTesting
  late final TargetDeviceSelection deviceSelection = TargetDeviceSelection(_logger);

  @override
  void startExtendedWirelessDeviceDiscovery({
    Duration? deviceDiscoveryTimeout,
  }) {
    if (deviceDiscoveryTimeout == null && _includeWirelessDevices) {
      _wirelessDevicesRefresh ??= _deviceManager.refreshExtendedWirelessDeviceDiscoverers(
        timeout: DeviceManager.minimumWirelessDeviceDiscoveryTimeout,
      );
    }
    return;
  }

  Future<List<Device>> _getRefreshedWirelessDevices({
    bool includeDevicesUnsupportedByProject = false,
  }) async {
    if (!_includeWirelessDevices) {
      return <Device>[];
    }
    startExtendedWirelessDeviceDiscovery();
    return () async {
      await _wirelessDevicesRefresh;
      return _deviceManager.getDevices(
        filter: DeviceDiscoveryFilter(
          deviceConnectionInterface: DeviceConnectionInterface.wireless,
          supportFilter: _defaultSupportFilter(includeDevicesUnsupportedByProject),
        ),
      );
    }();
  }

  Future<Device?> _waitForIOSDeviceToConnect(IOSDevice device) async {
    for (final DeviceDiscovery discoverer in _deviceManager.deviceDiscoverers) {
      if (discoverer is IOSDevices) {
        _logger.printStatus('Waiting for ${device.name} to connect...');
        final Status waitingStatus = _logger.startSpinner(
          timeout: const Duration(seconds: 30),
          warningColor: TerminalColor.red,
          slowWarningCallback: () {
            return 'The device was unable to connect after 30 seconds. Ensure the device is paired and unlocked.';
          },
        );
        final Device? connectedDevice = await discoverer.waitForDeviceToConnect(device, _logger);
        waitingStatus.stop();
        return connectedDevice;
      }
    }
    return null;
  }

  /// Find and return all target [Device]s based upon criteria entered by the
  /// user on the command line.
  ///
  /// When the user has specified `all` devices, return all devices meeting criteria.
  ///
  /// When the user has specified a device id/name, attempt to find an exact or
  /// partial match. If an exact match or a single partial match is found and
  /// the device is connected, return it immediately. If an exact match or a
  /// single partial match is found and the device is not connected and it's
  /// an iOS device, wait for it to connect.
  ///
  /// When multiple devices are found and there is a terminal attached to
  /// stdin, allow the user to select which device to use. When a terminal
  /// with stdin is not available, print a list of available devices and
  /// return null.
  ///
  /// When no devices meet user specifications, print a list of unsupported
  /// devices and return null.
  @override
  Future<List<Device>?> findAllTargetDevices({
    Duration? deviceDiscoveryTimeout,
    bool includeDevicesUnsupportedByProject = false,
  }) async {
    if (!globals.doctor!.canLaunchAnything) {
      _logger.printError(userMessages.flutterNoDevelopmentDevice);
      return null;
    }

    // When a user defines the timeout or filters to only attached devices,
    // use the super function that does not do longer wireless device
    // discovery and does not wait for devices to connect.
    if (deviceDiscoveryTimeout != null || deviceConnectionInterface == DeviceConnectionInterface.attached) {
      return super.findAllTargetDevices(
        deviceDiscoveryTimeout: deviceDiscoveryTimeout,
        includeDevicesUnsupportedByProject: includeDevicesUnsupportedByProject,
      );
    }

    // Start polling for wireless devices that need longer to load if it hasn't
    // already been started.
    startExtendedWirelessDeviceDiscovery();

    if (_deviceManager.hasSpecifiedDeviceId) {
      // Get devices matching the specified device regardless of whether they
      // are currently connected or not.
      // If there is a single matching connected device, return it immediately.
      // If the only device found is an iOS device that is not connected yet,
      // wait for it to connect.
      // If there are multiple matches, continue on to wait for all attached
      // and wireless devices to load so the user can select between all
      // connected matches.
      final List<Device> devices = await _getDeviceById(
        includeDevicesUnsupportedByProject: includeDevicesUnsupportedByProject,
        includeDisconnected: true,
      );
      if (devices.length == 1) {
        Device? matchedDevice = devices.first;
        if (!matchedDevice.isConnected && matchedDevice is IOSDevice) {
          matchedDevice = await _waitForIOSDeviceToConnect(matchedDevice);
        }
        if (matchedDevice != null && matchedDevice.isConnected) {
          return <Device>[matchedDevice];
        }
      }
    }

    final List<Device> attachedDevices = await _getAttachedDevices(
      supportFilter: _defaultSupportFilter(includeDevicesUnsupportedByProject),
    );

    // _getRefreshedWirelessDevices must be run after _getAttachedDevices is
    // finished to prevent non-iOS discoverers from running simultaneously.
    // `AndroidDevices` may error if run simultaneously.
    final Future<List<Device>> futureWirelessDevices = _getRefreshedWirelessDevices(
      includeDevicesUnsupportedByProject: includeDevicesUnsupportedByProject,
    );

    if (attachedDevices.isEmpty) {
      return _handleNoAttachedDevices(attachedDevices, futureWirelessDevices);
    } else if (_deviceManager.hasSpecifiedAllDevices) {
      return _handleAllDevices(attachedDevices, futureWirelessDevices);
    }
    // Even if there's only a single attached device, continue to
    // `_handleRemainingDevices` since there might be wireless devices
    // that are not loaded yet.
    return _handleRemainingDevices(attachedDevices, futureWirelessDevices);
  }

  /// When no supported attached devices are found, wait for wireless devices
  /// to load.
  ///
  /// If no wireless devices are found, continue to `_handleNoDevices`.
  ///
  /// If wireless devices are found, continue to `_handleMultipleDevices`.
  Future<List<Device>?> _handleNoAttachedDevices(
    List<Device> attachedDevices,
    Future<List<Device>> futureWirelessDevices,
  ) async {
    if (_includeAttachedDevices) {
      _logger.printStatus(_noAttachedCheckForWirelessMessage);
    } else {
      _logger.printStatus(_checkingForWirelessDevicesMessage);
    }

    final List<Device> wirelessDevices = await futureWirelessDevices;
    final List<Device> allDevices = attachedDevices + wirelessDevices;

    if (allDevices.isEmpty) {
      _logger.printStatus('');
      return _handleNoDevices();
    } else if (_deviceManager.hasSpecifiedAllDevices) {
      return allDevices;
    } else if (allDevices.length > 1) {
      _logger.printStatus('');
      return _handleMultipleDevices(attachedDevices, wirelessDevices);
    }
    return allDevices;
  }

  /// Wait for wireless devices to load and then return all attached and
  /// wireless devices.
  Future<List<Device>?> _handleAllDevices(
    List<Device> devices,
    Future<List<Device>> futureWirelessDevices,
  ) async {
    _logger.printStatus(_checkingForWirelessDevicesMessage);
    final List<Device> wirelessDevices = await futureWirelessDevices;
    return devices + wirelessDevices;
  }

  /// Determine which device to use when one or more are found.
  ///
  /// If user has not specified a device id/name, attempt to prioritize
  /// ephemeral devices. If a single ephemeral device is found, return it
  /// immediately.
  ///
  /// Otherwise, prompt the user to select a device if there is a terminal
  /// with stdin. If there is not a terminal, display the list of devices with
  /// instructions to use a device selection flag.
  Future<List<Device>?> _handleRemainingDevices(
    List<Device> attachedDevices,
    Future<List<Device>> futureWirelessDevices,
  ) async {
    final Device? ephemeralDevice = _deviceManager.getSingleEphemeralDevice(attachedDevices);
    if (ephemeralDevice != null) {
      return <Device>[ephemeralDevice];
    }

    if (!globals.terminal.stdinHasTerminal || !_logger.supportsColor) {
      _logger.printStatus(_checkingForWirelessDevicesMessage);
      final List<Device> wirelessDevices = await futureWirelessDevices;
      if (attachedDevices.length + wirelessDevices.length == 1) {
        return attachedDevices + wirelessDevices;
      }
      _logger.printStatus('');
      // If the terminal has stdin but does not support color/ANSI (which is
      // needed to clear lines), fallback to standard selection of device.
      if (globals.terminal.stdinHasTerminal && !_logger.supportsColor) {
        return _handleMultipleDevices(attachedDevices, wirelessDevices);
      }
      // If terminal does not have stdin, print out device list.
      return _printMultipleDevices(attachedDevices, wirelessDevices);
    }

    return _selectFromDevicesAndCheckForWireless(
      attachedDevices,
      futureWirelessDevices,
    );
  }

  /// Display a list of selectable attached devices and prompt the user to
  /// choose one.
  ///
  /// Also, display a message about waiting for wireless devices to load. Once
  /// wireless devices have loaded, update waiting message, device list, and
  /// selection options.
  ///
  /// Wait for the user to select a device.
  Future<List<Device>?> _selectFromDevicesAndCheckForWireless(
    List<Device> attachedDevices,
    Future<List<Device>> futureWirelessDevices,
  ) async {
    if (attachedDevices.length == 1 || !_deviceManager.hasSpecifiedDeviceId) {
      _logger.printStatus(_connectedDevicesMessage);
    } else if (_deviceManager.hasSpecifiedDeviceId) {
      // Multiple devices were found with part of the name/id provided.
      _logger.printStatus(_foundMultipleSpecifiedDevicesMessage(
        _deviceManager.specifiedDeviceId!,
      ));
    }

    // Display list of attached devices.
    await Device.printDevices(attachedDevices, _logger);

    // Display waiting message.
    _logger.printStatus('');
    _logger.printStatus(_checkingForWirelessDevicesMessage);
    _logger.printStatus('');

    // Start user device selection so user can select device while waiting
    // for wireless devices to load if they want.
    _displayDeviceOptions(attachedDevices);
    deviceSelection.devices = attachedDevices;
    final Future<Device> futureChosenDevice = deviceSelection.userSelectDevice();
    Device? chosenDevice;

    // Once wireless devices are found, we clear out the waiting message (3),
    // device option list (attachedDevices.length), and device option prompt (1).
    int numLinesToClear = attachedDevices.length + 4;

    futureWirelessDevices = futureWirelessDevices.then((List<Device> wirelessDevices) async {
      // If device is already chosen, don't update terminal with
      // wireless device list.
      if (chosenDevice != null) {
        return wirelessDevices;
      }

      final List<Device> allDevices = attachedDevices + wirelessDevices;

      if (_logger.isVerbose) {
        await _verbosePrintWirelessDevices(attachedDevices, wirelessDevices);
      } else {
        // Also clear any invalid device selections.
        numLinesToClear += deviceSelection.invalidAttempts;
        await _printWirelessDevices(wirelessDevices, numLinesToClear);
      }
      _logger.printStatus('');

      // Reprint device option list.
      _displayDeviceOptions(allDevices);
      deviceSelection.devices = allDevices;
      // Reprint device option prompt.
      _logger.printStatus(
        '$_chooseOneMessage: ',
        emphasis: true,
        newline: false,
      );
      return wirelessDevices;
    });

    // Used for testing.
    if (waitForWirelessBeforeInput) {
      await futureWirelessDevices;
    }

    // Wait for user to select a device.
    chosenDevice = await futureChosenDevice;

    // Update the [DeviceManager.specifiedDeviceId] so that the user will not
    // be prompted again.
    _deviceManager.specifiedDeviceId = chosenDevice.id;

    return <Device>[chosenDevice];
  }

  /// Reprint list of attached devices before printing list of wireless devices.
  Future<void> _verbosePrintWirelessDevices(
    List<Device> attachedDevices,
    List<Device> wirelessDevices,
  ) async {
    if (wirelessDevices.isEmpty) {
      _logger.printStatus(_noWirelessDevicesFoundMessage);
    }
    // The iOS xcdevice outputs once wireless devices are done loading, so
    // reprint attached devices so they're grouped with the wireless ones.
    _logger.printStatus(_connectedDevicesMessage);
    await Device.printDevices(attachedDevices, _logger);

    if (wirelessDevices.isNotEmpty) {
      _logger.printStatus('');
      _logger.printStatus(_wirelesslyConnectedDevicesMessage);
      await Device.printDevices(wirelessDevices, _logger);
    }
  }

  /// Clear [numLinesToClear] lines from terminal. Print message and list of
  /// wireless devices.
  Future<void> _printWirelessDevices(
    List<Device> wirelessDevices,
    int numLinesToClear,
  ) async {
    _logger.printStatus(
      globals.terminal.clearLines(numLinesToClear),
      newline: false,
    );
    _logger.printStatus('');
    if (wirelessDevices.isEmpty) {
      _logger.printStatus(_noWirelessDevicesFoundMessage);
    } else {
      _logger.printStatus(_wirelesslyConnectedDevicesMessage);
      await Device.printDevices(wirelessDevices, _logger);
    }
  }
}

@visibleForTesting
class TargetDeviceSelection {
  TargetDeviceSelection(this._logger);

  List<Device> devices = <Device>[];
  final Logger _logger;
  int invalidAttempts = 0;

  /// Prompt user to select a device and wait until they select a valid device.
  ///
  /// If the user selects `q`, exit the tool.
  ///
  /// If the user selects an invalid number, reprompt them and continue waiting.
  Future<Device> userSelectDevice() async {
    Device? chosenDevice;
    while (chosenDevice == null) {
      final String userInputString = await readUserInput();
      if (userInputString.toLowerCase() == 'q') {
        throwToolExit('');
      }
      final int deviceIndex = int.parse(userInputString) - 1;
      if (deviceIndex < devices.length) {
        chosenDevice = devices[deviceIndex];
      }
    }

    return chosenDevice;
  }

  /// Prompt user to select a device and wait until they select a valid
  /// character.
  ///
  /// Only allow input of a number or `q`.
  @visibleForTesting
  Future<String> readUserInput() async {
    final RegExp pattern = RegExp(r'\d+$|q', caseSensitive: false);
    String? choice;
    globals.terminal.singleCharMode = true;
    while (choice == null || choice.length > 1 || !pattern.hasMatch(choice)) {
      _logger.printStatus(_chooseOneMessage, emphasis: true, newline: false);
      // prompt ends with ': '
      _logger.printStatus(': ', emphasis: true, newline: false);
      choice = (await globals.terminal.keystrokes.first).trim();
      _logger.printStatus(choice);
      invalidAttempts++;
    }
    globals.terminal.singleCharMode = false;
    return choice;
  }
}
