// 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:io';

import 'package:path/path.dart' as path;

import '../framework/framework.dart';
import '../framework/ios.dart';
import '../framework/task_result.dart';
import '../framework/utils.dart';

/// Combines several TaskFunctions with trivial success value into one.
TaskFunction combine(List<TaskFunction> tasks) {
  return () async {
    for (final TaskFunction task in tasks) {
      final TaskResult result = await task();
      if (result.failed) {
        return result;
      }
    }
    return TaskResult.success(null);
  };
}

/// Defines task that creates new Flutter project, adds a local and remote
/// plugin, and then builds the specified [buildTarget].
class PluginTest {
  PluginTest(
    this.buildTarget,
    this.options, {
    this.pluginCreateEnvironment,
    this.appCreateEnvironment,
    this.dartOnlyPlugin = false,
    this.sharedDarwinSource = false,
    this.template = 'plugin',
  });

  final String buildTarget;
  final List<String> options;
  final Map<String, String>? pluginCreateEnvironment;
  final Map<String, String>? appCreateEnvironment;
  final bool dartOnlyPlugin;
  final bool sharedDarwinSource;
  final String template;

  Future<TaskResult> call() async {
    final Directory tempDir =
        Directory.systemTemp.createTempSync('flutter_devicelab_plugin_test.');
    // FFI plugins do not have support for `flutter test`.
    // `flutter test` does not do a native build.
    // Supporting `flutter test` would require invoking a native build.
    final bool runFlutterTest = template != 'plugin_ffi';
    try {
      section('Create plugin');
      final _FlutterProject plugin = await _FlutterProject.create(
          tempDir, options, buildTarget,
          name: 'plugintest', template: template, environment: pluginCreateEnvironment);
      if (dartOnlyPlugin) {
        await plugin.convertDefaultPluginToDartPlugin();
      }
      if (sharedDarwinSource) {
        await plugin.convertDefaultPluginToSharedDarwinPlugin();
      }
      section('Test plugin');
      if (runFlutterTest) {
        await plugin.runFlutterTest();
        if (!dartOnlyPlugin) {
          await plugin.example.runNativeTests(buildTarget);
        }
      }
      section('Create Flutter app');
      final _FlutterProject app = await _FlutterProject.create(tempDir, options, buildTarget,
          name: 'plugintestapp', template: 'app', environment: appCreateEnvironment);
      try {
        section('Add plugins');
        await app.addPlugin('plugintest',
            pluginPath: path.join('..', 'plugintest'));
        await app.addPlugin('path_provider');
        section('Build app');
        await app.build(buildTarget, validateNativeBuildProject: !dartOnlyPlugin);
        if (runFlutterTest) {
          section('Test app');
          await app.runFlutterTest();
        }
      } finally {
        await plugin.delete();
        await app.delete();
      }
      return TaskResult.success(null);
    } catch (e) {
      return TaskResult.failure(e.toString());
    } finally {
      rmTree(tempDir);
    }
  }
}

class _FlutterProject {
  _FlutterProject(this.parent, this.name);

  final Directory parent;
  final String name;

  String get rootPath => path.join(parent.path, name);

  File get pubspecFile => File(path.join(rootPath, 'pubspec.yaml'));

  _FlutterProject get example {
    return _FlutterProject(Directory(path.join(rootPath)), 'example');
  }

  Future<void> addPlugin(String plugin, {String? pluginPath}) async {
    final File pubspec = pubspecFile;
    String content = await pubspec.readAsString();
    final String dependency =
        pluginPath != null ? '$plugin:\n    path: $pluginPath' : '$plugin:';
    content = content.replaceFirst(
      '\ndependencies:\n',
      '\ndependencies:\n  $dependency\n',
    );
    await pubspec.writeAsString(content, flush: true);
  }

  /// Converts a plugin created from the standard template to a Dart-only
  /// plugin.
  Future<void> convertDefaultPluginToDartPlugin() async {
    final String dartPluginClass = 'DartClassFor$name';
    // Convert the metadata.
    final File pubspec = pubspecFile;
    String content = await pubspec.readAsString();
    content = content.replaceAll(
      RegExp(r' pluginClass: .*?\n'),
      ' dartPluginClass: $dartPluginClass\n',
    );
    await pubspec.writeAsString(content, flush: true);

    // Add the Dart registration hook that the build will generate a call to.
    final File dartCode = File(path.join(rootPath, 'lib', '$name.dart'));
    content = await dartCode.readAsString();
    content = '''
$content

class $dartPluginClass {
  static void registerWith() {}
}
''';
    await dartCode.writeAsString(content, flush: true);

    // Remove any native plugin code.
    const List<String> platforms = <String>[
      'android',
      'ios',
      'linux',
      'macos',
      'windows',
    ];
    for (final String platform in platforms) {
      final Directory platformDir = Directory(path.join(rootPath, platform));
      if (platformDir.existsSync()) {
        await platformDir.delete(recursive: true);
      }
    }
  }

  /// Converts an iOS/macOS plugin created from the standard template to a shared
  /// darwin directory plugin.
  Future<void> convertDefaultPluginToSharedDarwinPlugin() async {
    // Convert the metadata.
    final File pubspec = pubspecFile;
    String pubspecContent = await pubspec.readAsString();
    const String originalIOSKey = '\n      ios:\n';
    const String originalMacOSKey = '\n      macos:\n';
    if (!pubspecContent.contains(originalIOSKey) || !pubspecContent.contains(originalMacOSKey)) {
      print(pubspecContent);
      throw TaskResult.failure('Missing expected darwin platform plugin keys');
    }
    pubspecContent = pubspecContent.replaceAll(
        originalIOSKey,
        '$originalIOSKey        sharedDarwinSource: true\n'
    );
    pubspecContent = pubspecContent.replaceAll(
        originalMacOSKey,
        '$originalMacOSKey        sharedDarwinSource: true\n'
    );
    await pubspec.writeAsString(pubspecContent, flush: true);

    // Copy ios to darwin, and delete macos.
    final Directory iosDir = Directory(path.join(rootPath, 'ios'));
    final Directory darwinDir = Directory(path.join(rootPath, 'darwin'));
    recursiveCopy(iosDir, darwinDir);

    await iosDir.delete(recursive: true);
    await Directory(path.join(rootPath, 'macos')).delete(recursive: true);

    final File podspec = File(path.join(darwinDir.path, '$name.podspec'));
    String podspecContent = await podspec.readAsString();
    if (!podspecContent.contains('s.platform =')) {
      print(podspecContent);
      throw TaskResult.failure('Missing expected podspec platform');
    }

    // Remove "s.platform = :ios" to work on all platforms, including macOS.
    podspecContent = podspecContent.replaceFirst(RegExp(r'.*s\.platform.*'), '');
    podspecContent = podspecContent.replaceFirst("s.dependency 'Flutter'", "s.ios.dependency 'Flutter'\ns.osx.dependency 'FlutterMacOS'");

    await podspec.writeAsString(podspecContent, flush: true);

    // Make PlugintestPlugin.swift compile on iOS and macOS with target conditionals.
    final String pluginClass = '${name[0].toUpperCase()}${name.substring(1)}Plugin';
    print('pluginClass: $pluginClass');
    final File pluginRegister = File(path.join(darwinDir.path, 'Classes', '$pluginClass.swift'));
    final String pluginRegisterContent = '''
#if os(macOS)
import FlutterMacOS
#elseif os(iOS)
import Flutter
#endif

public class $pluginClass: NSObject, FlutterPlugin {
  public static func register(with registrar: FlutterPluginRegistrar) {
#if os(macOS)
    let channel = FlutterMethodChannel(name: "$name", binaryMessenger: registrar.messenger)
#elseif os(iOS)
    let channel = FlutterMethodChannel(name: "$name", binaryMessenger: registrar.messenger())
#endif
    let instance = $pluginClass()
    registrar.addMethodCallDelegate(instance, channel: channel)
  }

  public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
#if os(macOS)
    result("macOS " + ProcessInfo.processInfo.operatingSystemVersionString)
#elseif os(iOS)
    result("iOS " + UIDevice.current.systemVersion)
#endif
  }
}
''';
    await pluginRegister.writeAsString(pluginRegisterContent, flush: true);
  }

  Future<void> runFlutterTest() async {
    await inDirectory(Directory(rootPath), () async {
      await flutter('test');
    });
  }

  Future<void> runNativeTests(String buildTarget) async {
    // Native unit tests rely on building the app first to generate necessary
    // build files.
    await build(buildTarget, validateNativeBuildProject: false);

    switch (buildTarget) {
      case 'apk':
        if (await exec(
          path.join('.', 'gradlew'),
          <String>['testDebugUnitTest'],
          workingDirectory: path.join(rootPath, 'android'),
          canFail: true,
        ) != 0) {
          throw TaskResult.failure('Platform unit tests failed');
        }
      case 'ios':
        await testWithNewIOSSimulator('TestNativeUnitTests', (String deviceId) async {
          if (!await runXcodeTests(
            platformDirectory: path.join(rootPath, 'ios'),
            destination: 'id=$deviceId',
            configuration: 'Debug',
            testName: 'native_plugin_unit_tests_ios',
            skipCodesign: true,
          )) {
            throw TaskResult.failure('Platform unit tests failed');
          }
        });
      case 'linux':
        if (await exec(
          path.join(rootPath, 'build', 'linux', 'x64', 'release', 'plugins', 'plugintest', 'plugintest_test'),
          <String>[],
          canFail: true,
        ) != 0) {
          throw TaskResult.failure('Platform unit tests failed');
        }
      case 'macos':
        if (!await runXcodeTests(
          platformDirectory: path.join(rootPath, 'macos'),
          destination: 'platform=macOS',
          configuration: 'Debug',
          testName: 'native_plugin_unit_tests_macos',
          skipCodesign: true,
        )) {
          throw TaskResult.failure('Platform unit tests failed');
        }
      case 'windows':
        if (await exec(
          path.join(rootPath, 'build', 'windows', 'plugins', 'plugintest', 'Release', 'plugintest_test.exe'),
          <String>[],
          canFail: true,
        ) != 0) {
          throw TaskResult.failure('Platform unit tests failed');
        }
    }
  }

  static Future<_FlutterProject> create(
      Directory directory,
      List<String> options,
      String target,
      {
        required String name,
        required String template,
        Map<String, String>? environment,
      }) async {
    await inDirectory(directory, () async {
      await flutter(
        'create',
        options: <String>[
          '--template=$template',
          '--org',
          'io.flutter.devicelab',
          ...options,
          name,
        ],
        environment: environment,
      );
    });

    final _FlutterProject project = _FlutterProject(directory, name);
    if (template == 'plugin' && (target == 'ios' || target == 'macos')) {
      project._reduceDarwinPluginMinimumVersion(name, target);
    }
    return project;
  }

  // Make the platform version artificially low to test that the "deployment
  // version too low" warning is never emitted.
  void _reduceDarwinPluginMinimumVersion(String plugin, String target) {
    final File podspec = File(path.join(rootPath, target, '$plugin.podspec'));
    if (!podspec.existsSync()) {
      throw TaskResult.failure('podspec file missing at ${podspec.path}');
    }
    final String versionString = target == 'ios'
        ? "s.platform = :ios, '11.0'"
        : "s.platform = :osx, '10.11'";
    String podspecContent = podspec.readAsStringSync();
    if (!podspecContent.contains(versionString)) {
      throw TaskResult.failure('Update this test to match plugin minimum $target deployment version');
    }
    // Add transitive dependency on AppAuth 1.6 targeting iOS 8 and macOS 10.9, which no longer builds in Xcode
    // to test the version is forced higher and builds.
    const String iosContent = '''
s.platform = :ios, '10.0'
s.dependency 'AppAuth', '1.6.0'
''';

    const String macosContent = '''
s.platform = :osx, '10.8'
s.dependency 'AppAuth', '1.6.0'
''';

    podspecContent = podspecContent.replaceFirst(versionString, target == 'ios' ? iosContent : macosContent);
    podspec.writeAsStringSync(podspecContent, flush: true);
  }

  Future<void> build(String target, {bool validateNativeBuildProject = true}) async {
    await inDirectory(Directory(rootPath), () async {
      final String buildOutput =  await evalFlutter('build', options: <String>[
        target,
        '-v',
        if (target == 'ios')
          '--no-codesign',
      ]);

      if (target == 'ios' || target == 'macos') {
        // This warning is confusing and shouldn't be emitted. Plugins often support lower versions than the
        // Flutter app, but as long as they support the minimum it will work.
        // warning: The iOS deployment target 'IPHONEOS_DEPLOYMENT_TARGET' is set to 8.0,
        // but the range of supported deployment target versions is 9.0 to 14.0.99.
        //
        // (or "The macOS deployment target 'MACOSX_DEPLOYMENT_TARGET'"...)
        if (buildOutput.contains('is set to 10.0, but the range of supported deployment target versions') ||
            buildOutput.contains('is set to 10.8, but the range of supported deployment target versions')) {
          throw TaskResult.failure('Minimum plugin version warning present');
        }

        if (validateNativeBuildProject) {
          final File podsProject = File(path.join(rootPath, target, 'Pods', 'Pods.xcodeproj', 'project.pbxproj'));
          if (!podsProject.existsSync()) {
            throw TaskResult.failure('Xcode Pods project file missing at ${podsProject.path}');
          }

          final String podsProjectContent = podsProject.readAsStringSync();
          if (target == 'ios') {
            // Plugins with versions lower than the app version should not have IPHONEOS_DEPLOYMENT_TARGET set.
            // The plugintest plugin target should not have IPHONEOS_DEPLOYMENT_TARGET set since it has been lowered
            // in _reduceDarwinPluginMinimumVersion to 10, which is below the target version of 11.
            if (podsProjectContent.contains('IPHONEOS_DEPLOYMENT_TARGET = 10')) {
              throw TaskResult.failure('Plugin build setting IPHONEOS_DEPLOYMENT_TARGET not removed');
            }
            // Transitive dependency AppAuth targeting too-low 8.0 was not fixed.
            if (podsProjectContent.contains('IPHONEOS_DEPLOYMENT_TARGET = 8')) {
              throw TaskResult.failure('Transitive dependency build setting IPHONEOS_DEPLOYMENT_TARGET=8 not removed');
            }
            if (!podsProjectContent.contains(r'"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "$(inherited) i386";')) {
              throw TaskResult.failure(r'EXCLUDED_ARCHS is not "$(inherited) i386"');
            }
          } else if (target == 'macos') {
            // Same for macOS deployment target, but 10.8.
            // The plugintest target should not have MACOSX_DEPLOYMENT_TARGET set.
            if (podsProjectContent.contains('MACOSX_DEPLOYMENT_TARGET = 10.8')) {
              throw TaskResult.failure('Plugin build setting MACOSX_DEPLOYMENT_TARGET not removed');
            }
            // Transitive dependency AppAuth targeting too-low 10.9 was not fixed.
            if (podsProjectContent.contains('MACOSX_DEPLOYMENT_TARGET = 10.9')) {
              throw TaskResult.failure('Transitive dependency build setting MACOSX_DEPLOYMENT_TARGET=10.9 not removed');
            }
          }
        }
      }
    });
  }

  Future<void> delete() async {
    if (Platform.isWindows) {
      // A running Gradle daemon might prevent us from deleting the project
      // folder on Windows.
      final String wrapperPath =
          path.absolute(path.join(rootPath, 'android', 'gradlew.bat'));
      if (File(wrapperPath).existsSync()) {
        await exec(wrapperPath, <String>['--stop'], canFail: true);
      }
      // TODO(ianh): Investigating if flakiness is timing dependent.
      await Future<void>.delayed(const Duration(seconds: 10));
    }
    rmTree(parent);
  }
}