// 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 'dart:async';
import 'dart:io' as io;

import 'package:fake_async/fake_async.dart';
import 'package:file/memory.dart';
import 'package:flutter_tools/src/application_package.dart';
import 'package:flutter_tools/src/base/async_guard.dart';
import 'package:flutter_tools/src/base/common.dart';
import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/base/io.dart';
import 'package:flutter_tools/src/base/logger.dart';
import 'package:flutter_tools/src/base/platform.dart';
import 'package:flutter_tools/src/base/signals.dart';
import 'package:flutter_tools/src/build_info.dart';
import 'package:flutter_tools/src/cache.dart';
import 'package:flutter_tools/src/commands/drive.dart';
import 'package:flutter_tools/src/dart/pub.dart';
import 'package:flutter_tools/src/device.dart';
import 'package:flutter_tools/src/drive/drive_service.dart';
import 'package:flutter_tools/src/ios/devices.dart';
import 'package:flutter_tools/src/project.dart';
import 'package:package_config/package_config.dart';
import 'package:test/fake.dart';

import '../../src/common.dart';
import '../../src/context.dart';
import '../../src/test_flutter_command_runner.dart';

void main() {
  late FileSystem fileSystem;
  late BufferLogger logger;
  late Platform platform;
  late FakeDeviceManager fakeDeviceManager;
  late FakeSignals signals;

  setUp(() {
    fileSystem = MemoryFileSystem.test();
    logger = BufferLogger.test();
    platform = FakePlatform();
    fakeDeviceManager = FakeDeviceManager();
    signals = FakeSignals();
  });

  setUpAll(() {
    Cache.disableLocking();
  });

  tearDownAll(() {
    Cache.enableLocking();
  });

  testUsingContext('warns if screenshot is not supported but continues test', () async {
    final DriveCommand command = DriveCommand(
      fileSystem: fileSystem,
      logger: logger,
      platform: platform,
      signals: signals,
    );
    fileSystem.file('lib/main.dart').createSync(recursive: true);
    fileSystem.file('test_driver/main_test.dart').createSync(recursive: true);
    fileSystem.file('pubspec.yaml').createSync();
    fileSystem.directory('drive_screenshots').createSync();

    final Device screenshotDevice = ThrowingScreenshotDevice()
      ..supportsScreenshot = false;
    fakeDeviceManager.attachedDevices = <Device>[screenshotDevice];

    await expectLater(() => createTestCommandRunner(command).run(
      <String>[
        'drive',
        '--no-pub',
        '-d',
        screenshotDevice.id,
        '--screenshot',
        'drive_screenshots',
      ]),
      throwsToolExit(message: 'cannot start app'),
    );

    expect(logger.errorText, contains('Screenshot not supported for FakeDevice'));
    expect(logger.statusText, isEmpty);
  }, overrides: <Type, Generator>{
    FileSystem: () => fileSystem,
    ProcessManager: () => FakeProcessManager.any(),
    Pub: () => FakePub(),
    DeviceManager: () => fakeDeviceManager,
  });

  testUsingContext('does not register screenshot signal handler if --screenshot not provided', () async {
    final DriveCommand command = DriveCommand(
      fileSystem: fileSystem,
      logger: logger,
      platform: platform,
      signals: signals,
      flutterDriverFactory: FailingFakeFlutterDriverFactory(),
    );
    fileSystem.file('lib/main.dart').createSync(recursive: true);
    fileSystem.file('test_driver/main_test.dart').createSync(recursive: true);
    fileSystem.file('pubspec.yaml').createSync();
    fileSystem.directory('drive_screenshots').createSync();

    final Device screenshotDevice = ScreenshotDevice();
    fakeDeviceManager.attachedDevices = <Device>[screenshotDevice];

    await expectLater(() => createTestCommandRunner(command).run(
      <String>[
        'drive',
        '--no-pub',
        '-d',
        screenshotDevice.id,
        '--use-existing-app',
        'http://localhost:8181',
        '--keep-app-running',
      ]),
      throwsToolExit(),
    );
    expect(logger.statusText, isNot(contains('Screenshot written to ')));
    expect(signals.addedHandlers, isEmpty);
  }, overrides: <Type, Generator>{
    FileSystem: () => fileSystem,
    ProcessManager: () => FakeProcessManager.any(),
    Pub: () => FakePub(),
    DeviceManager: () => fakeDeviceManager,
  });

  testUsingContext('takes screenshot and rethrows on drive exception', () async {
    final DriveCommand command = DriveCommand(
      fileSystem: fileSystem,
      logger: logger,
      platform: platform,
      signals: signals,
    );
    fileSystem.file('lib/main.dart').createSync(recursive: true);
    fileSystem.file('test_driver/main_test.dart').createSync(recursive: true);
    fileSystem.file('pubspec.yaml').createSync();
    fileSystem.directory('drive_screenshots').createSync();

    final Device screenshotDevice = ThrowingScreenshotDevice();
    fakeDeviceManager.attachedDevices = <Device>[screenshotDevice];

    await expectLater(() => createTestCommandRunner(command).run(
      <String>[
        'drive',
        '--no-pub',
        '-d',
        screenshotDevice.id,
        '--screenshot',
        'drive_screenshots',
      ]),
      throwsToolExit(message: 'cannot start app'),
    );

    expect(logger.statusText, contains('Screenshot written to drive_screenshots/drive_01.png'));
    expect(logger.statusText, isNot(contains('drive_02.png')));
  }, overrides: <Type, Generator>{
    FileSystem: () => fileSystem,
    ProcessManager: () => FakeProcessManager.any(),
    Pub: () => FakePub(),
    DeviceManager: () => fakeDeviceManager,
  });

  testUsingContext('takes screenshot on drive test failure', () async {
    final DriveCommand command = DriveCommand(
      fileSystem: fileSystem,
      logger: logger,
      platform: platform,
      signals: signals,
      flutterDriverFactory: FailingFakeFlutterDriverFactory(),
    );

    fileSystem.file('lib/main.dart').createSync(recursive: true);
    fileSystem.file('test_driver/main_test.dart').createSync(recursive: true);
    fileSystem.file('pubspec.yaml').createSync();
    fileSystem.directory('drive_screenshots').createSync();

    final Device screenshotDevice = ScreenshotDevice();
    fakeDeviceManager.attachedDevices = <Device>[screenshotDevice];

    await expectLater(() => createTestCommandRunner(command).run(
      <String>[
        'drive',
        '--no-pub',
        '-d',
        screenshotDevice.id,
        '--use-existing-app',
        'http://localhost:8181',
        '--keep-app-running',
        '--screenshot',
        'drive_screenshots',
      ]),
      throwsToolExit(),
    );

    // Takes the screenshot before the application would be killed (if --keep-app-running not passed).
    expect(logger.statusText, contains('Screenshot written to drive_screenshots/drive_01.png\n'
        'Leaving the application running.'));
    expect(logger.statusText, isNot(contains('drive_02.png')));
  }, overrides: <Type, Generator>{
    FileSystem: () => fileSystem,
    ProcessManager: () => FakeProcessManager.any(),
    Pub: () => FakePub(),
    DeviceManager: () => fakeDeviceManager,
  });

  testUsingContext('drive --screenshot errors but does not fail if screenshot fails', () async {
    final DriveCommand command = DriveCommand(
      fileSystem: fileSystem,
      logger: logger,
      platform: platform,
      signals: signals,
    );

    fileSystem.file('lib/main.dart').createSync(recursive: true);
    fileSystem.file('test_driver/main_test.dart').createSync(recursive: true);
    fileSystem.file('pubspec.yaml').createSync();
    fileSystem.file('drive_screenshots').createSync();

    final Device screenshotDevice = ThrowingScreenshotDevice();
    fakeDeviceManager.attachedDevices = <Device>[screenshotDevice];

    await expectLater(() => createTestCommandRunner(command).run(
      <String>[
        'drive',
        '--no-pub',
        '-d',
        screenshotDevice.id,
        '--screenshot',
        'drive_screenshots',
      ]),
      throwsToolExit(message: 'cannot start app'),
    );

    expect(logger.statusText, isEmpty);
    expect(logger.errorText, contains('Error taking screenshot: FileSystemException: Not a directory'));
  }, overrides: <Type, Generator>{
    FileSystem: () => fileSystem,
    ProcessManager: () => FakeProcessManager.any(),
    Pub: () => FakePub(),
    DeviceManager: () => fakeDeviceManager,
  });

  testUsingContext('drive --timeout takes screenshot and tool exits after timeout', () async {
    final DriveCommand command = DriveCommand(
      fileSystem: fileSystem,
      logger: logger,
      platform: platform,
      signals: Signals.test(),
      flutterDriverFactory: NeverEndingFlutterDriverFactory(() {}),
    );

    fileSystem.file('lib/main.dart').createSync(recursive: true);
    fileSystem.file('test_driver/main_test.dart').createSync(recursive: true);
    fileSystem.file('pubspec.yaml').createSync();
    fileSystem.directory('drive_screenshots').createSync();

    final ScreenshotDevice screenshotDevice = ScreenshotDevice();
    fakeDeviceManager.attachedDevices = <Device>[screenshotDevice];

    expect(screenshotDevice.screenshots, isEmpty);
    bool caughtToolExit = false;
    FakeAsync().run<void>((FakeAsync time) {
      // Because the tool exit will be thrown asynchronously by a [Timer],
      // use [asyncGuard] to catch it
      asyncGuard<void>(
        () => createTestCommandRunner(command).run(
          <String>[
            'drive',
            '--no-pub',
            '-d',
            screenshotDevice.id,
            '--use-existing-app',
            'http://localhost:8181',
            '--screenshot',
            'drive_screenshots',
            '--timeout',
            '300', // 5 minutes
          ],
        ),
        onError: (Object error) {
          expect(error, isA<ToolExit>());
          expect(
            (error as ToolExit).message,
            contains('Timed out after 300 seconds'),
          );
          caughtToolExit = true;
        }
      );
      time.elapse(const Duration(seconds: 299));
      expect(screenshotDevice.screenshots, isEmpty);
      time.elapse(const Duration(seconds: 2));
      expect(
        screenshotDevice.screenshots,
        contains(isA<File>().having(
          (File file) => file.path,
          'path',
          'drive_screenshots/drive_01.png',
        )),
      );
    });
    expect(caughtToolExit, isTrue);
  }, overrides: <Type, Generator>{
    FileSystem: () => fileSystem,
    ProcessManager: () => FakeProcessManager.any(),
    Pub: () => FakePub(),
    DeviceManager: () => fakeDeviceManager,
  });

  testUsingContext('drive --screenshot takes screenshot if sent a registered signal', () async {
    final FakeProcessSignal signal = FakeProcessSignal();
    final ProcessSignal signalUnderTest = ProcessSignal(signal);
    final DriveCommand command = DriveCommand(
      fileSystem: fileSystem,
      logger: logger,
      platform: platform,
      signals: Signals.test(),
      flutterDriverFactory: NeverEndingFlutterDriverFactory(() {
        signal.controller.add(signal);
      }),
      signalsToHandle: <ProcessSignal>{signalUnderTest},
    );

    fileSystem.file('lib/main.dart').createSync(recursive: true);
    fileSystem.file('test_driver/main_test.dart').createSync(recursive: true);
    fileSystem.file('pubspec.yaml').createSync();
    fileSystem.directory('drive_screenshots').createSync();

    final ScreenshotDevice screenshotDevice = ScreenshotDevice();
    fakeDeviceManager.attachedDevices = <Device>[screenshotDevice];

    expect(screenshotDevice.screenshots, isEmpty);

    // This command will never complete. In reality, a real signal would have
    // shut down the Dart process.
    unawaited(
      createTestCommandRunner(command).run(
        <String>[
          'drive',
          '--no-pub',
          '-d',
          screenshotDevice.id,
          '--use-existing-app',
          'http://localhost:8181',
          '--screenshot',
          'drive_screenshots',
        ],
      ),
    );

    await screenshotDevice.firstScreenshot;
    expect(
      screenshotDevice.screenshots,
      contains(isA<File>().having(
        (File file) => file.path,
        'path',
        'drive_screenshots/drive_01.png',
      )),
    );
  }, overrides: <Type, Generator>{
    FileSystem: () => fileSystem,
    ProcessManager: () => FakeProcessManager.any(),
    Pub: () => FakePub(),
    DeviceManager: () => fakeDeviceManager,
  });

  testUsingContext('shouldRunPub is true unless user specifies --no-pub', () async {
    final DriveCommand command = DriveCommand(
      fileSystem: fileSystem,
      logger: logger,
      platform: platform,
      signals: signals,
    );

    fileSystem.file('lib/main.dart').createSync(recursive: true);
    fileSystem.file('test_driver/main_test.dart').createSync(recursive: true);
    fileSystem.file('pubspec.yaml').createSync();

    try {
      await createTestCommandRunner(command).run(const <String>['drive', '--no-pub']);
    } on Exception {
      // Expected to throw
    }

    expect(command.shouldRunPub, false);

    try {
      await createTestCommandRunner(command).run(const <String>['drive']);
    } on Exception {
      // Expected to throw
    }

    expect(command.shouldRunPub, true);
  }, overrides: <Type, Generator>{
    FileSystem: () => fileSystem,
    ProcessManager: () => FakeProcessManager.any(),
    Pub: () => FakePub(),
  });

  testUsingContext('flags propagate to debugging options', () async {
    final DriveCommand command = DriveCommand(
      fileSystem: fileSystem,
      logger: logger,
      platform: platform,
      signals: signals,
    );

    fileSystem.file('lib/main.dart').createSync(recursive: true);
    fileSystem.file('test_driver/main_test.dart').createSync(recursive: true);
    fileSystem.file('pubspec.yaml').createSync();

    await expectLater(() => createTestCommandRunner(command).run(<String>[
      'drive',
      '--start-paused',
      '--disable-service-auth-codes',
      '--trace-skia',
      '--trace-systrace',
      '--trace-to-file=path/to/trace.binpb',
      '--verbose-system-logs',
      '--null-assertions',
      '--native-null-assertions',
      '--enable-impeller',
      '--trace-systrace',
      '--enable-software-rendering',
      '--skia-deterministic-rendering',
      '--enable-embedder-api',
      '--ci',
    ]), throwsToolExit());

    final DebuggingOptions options = await command.createDebuggingOptions(false);

    expect(options.startPaused, true);
    expect(options.disableServiceAuthCodes, true);
    expect(options.traceSkia, true);
    expect(options.traceSystrace, true);
    expect(options.traceToFile, 'path/to/trace.binpb');
    expect(options.verboseSystemLogs, true);
    expect(options.nullAssertions, true);
    expect(options.nativeNullAssertions, true);
    expect(options.enableImpeller, ImpellerStatus.enabled);
    expect(options.traceSystrace, true);
    expect(options.enableSoftwareRendering, true);
    expect(options.skiaDeterministicRendering, true);
    expect(options.usingCISystem, true);
  }, overrides: <Type, Generator>{
    Cache: () => Cache.test(processManager: FakeProcessManager.any()),
    FileSystem: () => MemoryFileSystem.test(),
    ProcessManager: () => FakeProcessManager.any(),
  });

  testUsingContext('Port publication not disabled for wireless device', () async {
    final DriveCommand command = DriveCommand(
      fileSystem: fileSystem,
      logger: logger,
      platform: platform,
      signals: signals,
    );

    fileSystem.file('lib/main.dart').createSync(recursive: true);
    fileSystem.file('test_driver/main_test.dart').createSync(recursive: true);
    fileSystem.file('pubspec.yaml').createSync();

    final Device wirelessDevice = FakeIosDevice()
      ..connectionInterface = DeviceConnectionInterface.wireless;
    fakeDeviceManager.wirelessDevices = <Device>[wirelessDevice];

    await expectLater(() => createTestCommandRunner(command).run(<String>[
      'drive',
    ]), throwsToolExit());

    final DebuggingOptions options = await command.createDebuggingOptions(false);
    expect(options.disablePortPublication, false);
  }, overrides: <Type, Generator>{
    Cache: () => Cache.test(processManager: FakeProcessManager.any()),
    FileSystem: () => MemoryFileSystem.test(),
    ProcessManager: () => FakeProcessManager.any(),
    DeviceManager: () => fakeDeviceManager,
  });

  testUsingContext('Port publication is disabled for wired device', () async {
    final DriveCommand command = DriveCommand(
      fileSystem: fileSystem,
      logger: logger,
      platform: platform,
      signals: signals,
    );

    fileSystem.file('lib/main.dart').createSync(recursive: true);
    fileSystem.file('test_driver/main_test.dart').createSync(recursive: true);
    fileSystem.file('pubspec.yaml').createSync();

    await expectLater(() => createTestCommandRunner(command).run(<String>[
      'drive',
    ]), throwsToolExit());

    final Device usbDevice = FakeIosDevice()
      ..connectionInterface = DeviceConnectionInterface.attached;
    fakeDeviceManager.attachedDevices = <Device>[usbDevice];

    final DebuggingOptions options = await command.createDebuggingOptions(false);
    expect(options.disablePortPublication, true);
  }, overrides: <Type, Generator>{
    Cache: () => Cache.test(processManager: FakeProcessManager.any()),
    FileSystem: () => MemoryFileSystem.test(),
    ProcessManager: () => FakeProcessManager.any(),
    DeviceManager: () => fakeDeviceManager,
  });

  testUsingContext('Port publication does not default to enabled for wireless device if flag manually added', () async {
    final DriveCommand command = DriveCommand(
      fileSystem: fileSystem,
      logger: logger,
      platform: platform,
      signals: signals,
    );

    fileSystem.file('lib/main.dart').createSync(recursive: true);
    fileSystem.file('test_driver/main_test.dart').createSync(recursive: true);
    fileSystem.file('pubspec.yaml').createSync();

    final Device wirelessDevice = FakeIosDevice()
      ..connectionInterface = DeviceConnectionInterface.wireless;
    fakeDeviceManager.wirelessDevices = <Device>[wirelessDevice];

    await expectLater(() => createTestCommandRunner(command).run(<String>[
      'drive',
      '--no-publish-port'
    ]), throwsToolExit());

    final DebuggingOptions options = await command.createDebuggingOptions(false);
    expect(options.disablePortPublication, true);
  }, overrides: <Type, Generator>{
    Cache: () => Cache.test(processManager: FakeProcessManager.any()),
    FileSystem: () => MemoryFileSystem.test(),
    ProcessManager: () => FakeProcessManager.any(),
    DeviceManager: () => fakeDeviceManager,
  });
}

class ThrowingScreenshotDevice extends ScreenshotDevice {
  @override
  Future<LaunchResult> startApp(
    ApplicationPackage? package, {
      String? mainPath,
      String? route,
      DebuggingOptions? debuggingOptions,
      Map<String, dynamic>? platformArgs,
      bool prebuiltApplication = false,
      bool usesTerminalUi = true,
      bool ipv6 = false,
      String? userIdentifier,
    }) async {
    throwToolExit('cannot start app');
  }
}

class ScreenshotDevice extends Fake implements Device {
  final List<File> screenshots = <File>[];

  final Completer<void> _firstScreenshotCompleter = Completer<void>();

  /// A Future that completes when [takeScreenshot] is called the first time.
  Future<void> get firstScreenshot => _firstScreenshotCompleter.future;

  @override
  final String name = 'FakeDevice';

  @override
  final Category category = Category.mobile;

  @override
  final String id = 'fake_device';

  @override
  Future<TargetPlatform> get targetPlatform async => TargetPlatform.android;

  @override
  bool supportsScreenshot = true;

  @override
  bool get isConnected => true;

  @override
  Future<LaunchResult> startApp(
    ApplicationPackage? package, {
      String? mainPath,
      String? route,
      DebuggingOptions? debuggingOptions,
      Map<String, dynamic>? platformArgs,
      bool prebuiltApplication = false,
      bool usesTerminalUi = true,
      bool ipv6 = false,
      String? userIdentifier,
    }) async => LaunchResult.succeeded();

  @override
  Future<void> takeScreenshot(File outputFile) async {
    if (!_firstScreenshotCompleter.isCompleted) {
      _firstScreenshotCompleter.complete();
    }
    screenshots.add(outputFile);
  }
}

class FakePub extends Fake implements Pub {
  @override
  Future<void> get({
    PubContext? context,
    required FlutterProject project,
    bool upgrade = false,
    bool offline = false,
    bool generateSyntheticPackage = false,
    String? flutterRootOverride,
    bool checkUpToDate = false,
    bool shouldSkipThirdPartyGenerator = true,
    PubOutputMode outputMode = PubOutputMode.all,
  }) async { }
}

/// A [FlutterDriverFactory] that creates a [NeverEndingDriverService].
class NeverEndingFlutterDriverFactory extends Fake implements FlutterDriverFactory {
  NeverEndingFlutterDriverFactory(this.callback);

  final void Function() callback;

  @override
  DriverService createDriverService(bool web) => NeverEndingDriverService(callback);
}

/// A [DriverService] that will return a Future from [startTest] that will never complete.
///
/// This is to simulate when the test will take a long time, but a signal is
/// expected to interrupt the process.
class NeverEndingDriverService extends Fake implements DriverService {
  NeverEndingDriverService(this.callback);

  final void Function() callback;
  @override
  Future<void> reuseApplication(Uri vmServiceUri, Device device, DebuggingOptions debuggingOptions, bool ipv6) async { }

  @override
  Future<int> startTest(
    String testFile,
    List<String> arguments,
    Map<String, String> environment,
    PackageConfig packageConfig, {
      bool? headless,
      String? chromeBinary,
      String? browserName,
      bool? androidEmulator,
      int? driverPort,
      List<String>? webBrowserFlags,
      List<String>? browserDimension,
      String? profileMemory,
    }) async {
      callback();
      // return a Future that will never complete.
      return Completer<int>().future;
  }
}

class FailingFakeFlutterDriverFactory extends Fake implements FlutterDriverFactory {
  @override
  DriverService createDriverService(bool web) => FailingFakeDriverService();
}

class FailingFakeDriverService extends Fake implements DriverService {
  @override
  Future<void> reuseApplication(Uri vmServiceUri, Device device, DebuggingOptions debuggingOptions, bool ipv6) async { }

  @override
  Future<int> startTest(
    String testFile,
    List<String> arguments,
    Map<String, String> environment,
    PackageConfig packageConfig, {
      bool? headless,
      String? chromeBinary,
      String? browserName,
      bool? androidEmulator,
      int? driverPort,
      List<String>? webBrowserFlags,
      List<String>? browserDimension,
      String? profileMemory,
    }) async => 1;
}

class FakeProcessSignal extends Fake implements io.ProcessSignal {
  final StreamController<io.ProcessSignal> controller = StreamController<io.ProcessSignal>();

  @override
  Stream<io.ProcessSignal> watch() => controller.stream;
}

class FakeIosDevice extends Fake implements IOSDevice {
  @override
  DeviceConnectionInterface connectionInterface = DeviceConnectionInterface.attached;

  @override
  bool get isWirelesslyConnected =>
      connectionInterface == DeviceConnectionInterface.wireless;

  @override
  Future<TargetPlatform> get targetPlatform async => TargetPlatform.ios;
}

class FakeSignals extends Fake implements Signals {
  List<SignalHandler> addedHandlers = <SignalHandler>[];

  @override
  Object addHandler(ProcessSignal signal, SignalHandler handler) {
    addedHandlers.add(handler);
    return const Object();
  }

  @override
  Future<bool> removeHandler(ProcessSignal signal, Object token) async => true;
}