// 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 'package:process/process.dart';

import '../base/common.dart';
import '../base/context.dart';
import '../base/file_system.dart';
import '../base/io.dart';
import '../base/logger.dart';
import '../base/os.dart';
import '../base/platform.dart';
import '../base/user_messages.dart' hide userMessages;
import '../base/version.dart';
import '../convert.dart';
import '../doctor_validator.dart';
import '../features.dart';
import 'android_sdk.dart';
import 'android_studio.dart';

const int kAndroidSdkMinVersion = 29;
final Version kAndroidJavaMinVersion = Version(1, 8, 0);
final Version kAndroidSdkBuildToolsMinVersion = Version(28, 0, 3);

AndroidWorkflow? get androidWorkflow => context.get<AndroidWorkflow>();
AndroidValidator? get androidValidator => context.get<AndroidValidator>();
AndroidLicenseValidator? get androidLicenseValidator => context.get<AndroidLicenseValidator>();

enum LicensesAccepted {
  none,
  some,
  all,
  unknown,
}

final RegExp licenseCounts = RegExp(r'(\d+) of (\d+) SDK package licenses? not accepted.');
final RegExp licenseNotAccepted = RegExp(r'licenses? not accepted', caseSensitive: false);
final RegExp licenseAccepted = RegExp(r'All SDK package licenses accepted.');

class AndroidWorkflow implements Workflow {
  AndroidWorkflow({
    required AndroidSdk? androidSdk,
    required FeatureFlags featureFlags,
  }) : _androidSdk = androidSdk,
       _featureFlags = featureFlags;

  final AndroidSdk? _androidSdk;
  final FeatureFlags _featureFlags;

  @override
  bool get appliesToHostPlatform => _featureFlags.isAndroidEnabled;

  @override
  bool get canListDevices => appliesToHostPlatform && _androidSdk != null
    && _androidSdk?.adbPath != null;

  @override
  bool get canLaunchDevices => appliesToHostPlatform && _androidSdk != null
    && _androidSdk?.adbPath != null
    && (_androidSdk?.validateSdkWellFormed().isEmpty ?? false);

  @override
  bool get canListEmulators => canListDevices && _androidSdk?.emulatorPath != null;
}

/// A validator that checks if the Android SDK and Java SDK are available and
/// installed correctly.
///
/// Android development requires the Android SDK, and at least one Java SDK. While
/// newer Java compilers can be used to compile the Java application code, the SDK
/// tools themselves required JDK 1.8. This older JDK is normally bundled with
/// Android Studio.
class AndroidValidator extends DoctorValidator {
  AndroidValidator({
    required AndroidSdk? androidSdk,
    required AndroidStudio? androidStudio,
    required FileSystem fileSystem,
    required Logger logger,
    required Platform platform,
    required ProcessManager processManager,
    required UserMessages userMessages,
  }) : _androidSdk = androidSdk,
       _androidStudio = androidStudio,
       _fileSystem = fileSystem,
       _logger = logger,
       _operatingSystemUtils = OperatingSystemUtils(
         fileSystem: fileSystem,
         logger: logger,
         platform: platform,
         processManager: processManager,
       ),
       _platform = platform,
       _processManager = processManager,
       _userMessages = userMessages,
       super('Android toolchain - develop for Android devices');

  final AndroidSdk? _androidSdk;
  final AndroidStudio? _androidStudio;
  final FileSystem _fileSystem;
  final Logger _logger;
  final OperatingSystemUtils _operatingSystemUtils;
  final Platform _platform;
  final ProcessManager _processManager;
  final UserMessages _userMessages;

  @override
  String get slowWarning => '${_task ?? 'This'} is taking a long time...';
  String? _task;

  /// Finds the semantic version anywhere in a text.
  static final RegExp _javaVersionPattern = RegExp(r'(\d+)(\.(\d+)(\.(\d+))?)?');

  /// `java -version` response is not only a number, but also includes other
  /// information eg. `openjdk version "1.7.0_212"`.
  /// This method extracts only the semantic version from that response.
  static String? _extractJavaVersion(String? text) {
    if (text == null || text.isEmpty) {
      return null;
    }
    final Match? match = _javaVersionPattern.firstMatch(text);
    if (match == null) {
      return null;
    }
    return text.substring(match.start, match.end);
  }

  /// Returns false if we cannot determine the Java version or if the version
  /// is older that the minimum allowed version of 1.8.
  Future<bool> _checkJavaVersion(String javaBinary, List<ValidationMessage> messages) async {
    _task = 'Checking Java status';
    try {
      if (!_processManager.canRun(javaBinary)) {
        messages.add(ValidationMessage.error(_userMessages.androidCantRunJavaBinary(javaBinary)));
        return false;
      }
      String? javaVersionText;
      try {
        _logger.printTrace('java -version');
        final ProcessResult result = await _processManager.run(<String>[javaBinary, '-version']);
        if (result.exitCode == 0) {
          final List<String> versionLines = (result.stderr as String).split('\n');
          javaVersionText = versionLines.length >= 2 ? versionLines[1] : versionLines[0];
        }
      } on Exception catch (error) {
        _logger.printTrace(error.toString());
      }
      final Version? javaVersion = Version.parse(_extractJavaVersion(javaVersionText));
      if (javaVersionText == null || javaVersionText.isEmpty || javaVersion == null) {
        // Could not determine the java version.
        messages.add(ValidationMessage.error(_userMessages.androidUnknownJavaVersion));
        return false;
      }
      if (javaVersion < kAndroidJavaMinVersion) {
        messages.add(ValidationMessage.error(_userMessages.androidJavaMinimumVersion(javaVersionText)));
        return false;
      }
      messages.add(ValidationMessage(_userMessages.androidJavaVersion(javaVersionText)));
      return true;
    } finally {
      _task = null;
    }
  }

  @override
  Future<ValidationResult> validate() async {
    final List<ValidationMessage> messages = <ValidationMessage>[];
    final AndroidSdk? androidSdk = _androidSdk;
    if (androidSdk == null) {
      // No Android SDK found.
      if (_platform.environment.containsKey(kAndroidHome)) {
        final String androidHomeDir = _platform.environment[kAndroidHome]!;
        messages.add(ValidationMessage.error(_userMessages.androidBadSdkDir(kAndroidHome, androidHomeDir)));
      } else {
        // Instruct user to set [kAndroidSdkRoot] and not deprecated [kAndroidHome]
        // See https://github.com/flutter/flutter/issues/39301
        messages.add(ValidationMessage.error(_userMessages.androidMissingSdkInstructions(_platform)));
      }
      return ValidationResult(ValidationType.missing, messages);
    }

    messages.add(ValidationMessage(_userMessages.androidSdkLocation(androidSdk.directory.path)));

    _task = 'Validating Android SDK command line tools are available';
    if (!androidSdk.cmdlineToolsAvailable) {
      messages.add(ValidationMessage.error(_userMessages.androidMissingCmdTools));
      return ValidationResult(ValidationType.missing, messages);
    }

    _task = 'Validating Android SDK licenses';
    if (androidSdk.licensesAvailable && !androidSdk.platformToolsAvailable) {
      messages.add(ValidationMessage.hint(_userMessages.androidSdkLicenseOnly(kAndroidHome)));
      return ValidationResult(ValidationType.partial, messages);
    }

    String? sdkVersionText;
    final AndroidSdkVersion? androidSdkLatestVersion = androidSdk.latestVersion;
    if (androidSdkLatestVersion != null) {
      if (androidSdkLatestVersion.sdkLevel < kAndroidSdkMinVersion || androidSdkLatestVersion.buildToolsVersion < kAndroidSdkBuildToolsMinVersion) {
        messages.add(ValidationMessage.error(
          _userMessages.androidSdkBuildToolsOutdated(
            kAndroidSdkMinVersion,
            kAndroidSdkBuildToolsMinVersion.toString(),
            _platform,
          )),
        );
        return ValidationResult(ValidationType.missing, messages);
      }
      sdkVersionText = _userMessages.androidStatusInfo(androidSdkLatestVersion.buildToolsVersionName);

      messages.add(ValidationMessage(_userMessages.androidSdkPlatformToolsVersion(
        androidSdkLatestVersion.platformName,
        androidSdkLatestVersion.buildToolsVersionName)));
    } else {
      messages.add(ValidationMessage.error(_userMessages.androidMissingSdkInstructions(_platform)));
    }

    if (_platform.environment.containsKey(kAndroidHome)) {
      final String androidHomeDir = _platform.environment[kAndroidHome]!;
      messages.add(ValidationMessage('$kAndroidHome = $androidHomeDir'));
    }
    if (_platform.environment.containsKey(kAndroidSdkRoot)) {
      final String androidSdkRoot = _platform.environment[kAndroidSdkRoot]!;
      messages.add(ValidationMessage('$kAndroidSdkRoot = $androidSdkRoot'));
    }

    _task = 'Validating Android SDK';
    final List<String> validationResult = androidSdk.validateSdkWellFormed();

    if (validationResult.isNotEmpty) {
      // Android SDK is not functional.
      messages.addAll(validationResult.map<ValidationMessage>((String message) {
        return ValidationMessage.error(message);
      }));
      messages.add(ValidationMessage(_userMessages.androidSdkInstallHelp(_platform)));
      return ValidationResult(ValidationType.partial, messages, statusInfo: sdkVersionText);
    }

    _task = 'Finding Java binary';
    // Now check for the JDK.
    final String? javaBinary = AndroidSdk.findJavaBinary(
      androidStudio: _androidStudio,
      fileSystem: _fileSystem,
      operatingSystemUtils: _operatingSystemUtils,
      platform: _platform,
    );
    if (javaBinary == null) {
      messages.add(ValidationMessage.error(_userMessages.androidMissingJdk));
      return ValidationResult(ValidationType.partial, messages, statusInfo: sdkVersionText);
    }
    messages.add(ValidationMessage(_userMessages.androidJdkLocation(javaBinary)));

    // Check JDK version.
    if (!await _checkJavaVersion(javaBinary, messages)) {
      return ValidationResult(ValidationType.partial, messages, statusInfo: sdkVersionText);
    }

    // Success.
    return ValidationResult(ValidationType.success, messages, statusInfo: sdkVersionText);
  }
}

/// A subvalidator that checks if the licenses within the detected Android
/// SDK have been accepted.
class AndroidLicenseValidator extends DoctorValidator {
  AndroidLicenseValidator({
    required AndroidSdk? androidSdk,
    required Platform platform,
    required OperatingSystemUtils operatingSystemUtils,
    required FileSystem fileSystem,
    required ProcessManager processManager,
    required Logger logger,
    required AndroidStudio? androidStudio,
    required Stdio stdio,
    required UserMessages userMessages,
  }) : _androidSdk = androidSdk,
       _platform = platform,
       _operatingSystemUtils = operatingSystemUtils,
       _fileSystem = fileSystem,
       _processManager = processManager,
       _logger = logger,
       _androidStudio = androidStudio,
       _stdio = stdio,
       _userMessages = userMessages,
       super('Android license subvalidator');

  final AndroidSdk? _androidSdk;
  final AndroidStudio? _androidStudio;
  final Stdio _stdio;
  final OperatingSystemUtils _operatingSystemUtils;
  final Platform _platform;
  final FileSystem _fileSystem;
  final ProcessManager _processManager;
  final Logger _logger;
  final UserMessages _userMessages;

  @override
  String get slowWarning => 'Checking Android licenses is taking an unexpectedly long time...';

  @override
  Future<ValidationResult> validate() async {
    final List<ValidationMessage> messages = <ValidationMessage>[];

    // Match pre-existing early termination behavior
    if (_androidSdk == null || _androidSdk?.latestVersion == null ||
        _androidSdk!.validateSdkWellFormed().isNotEmpty ||
        ! await _checkJavaVersionNoOutput()) {
      return ValidationResult(ValidationType.missing, messages);
    }

    final String sdkVersionText = _userMessages.androidStatusInfo(_androidSdk!.latestVersion!.buildToolsVersionName);

    // Check for licenses.
    switch (await licensesAccepted) {
      case LicensesAccepted.all:
        messages.add(ValidationMessage(_userMessages.androidLicensesAll));
        break;
      case LicensesAccepted.some:
        messages.add(ValidationMessage.hint(_userMessages.androidLicensesSome));
        return ValidationResult(ValidationType.partial, messages, statusInfo: sdkVersionText);
      case LicensesAccepted.none:
        messages.add(ValidationMessage.error(_userMessages.androidLicensesNone));
        return ValidationResult(ValidationType.partial, messages, statusInfo: sdkVersionText);
      case LicensesAccepted.unknown:
        messages.add(ValidationMessage.error(_userMessages.androidLicensesUnknown(_platform)));
        return ValidationResult(ValidationType.partial, messages, statusInfo: sdkVersionText);
    }
    return ValidationResult(ValidationType.success, messages, statusInfo: sdkVersionText);
  }

  Future<bool> _checkJavaVersionNoOutput() async {
    final String? javaBinary = AndroidSdk.findJavaBinary(
      androidStudio: _androidStudio,
      fileSystem: _fileSystem,
      operatingSystemUtils: _operatingSystemUtils,
      platform: _platform,
    );
    if (javaBinary == null) {
      return false;
    }
    if (!_processManager.canRun(javaBinary)) {
      return false;
    }
    String? javaVersion;
    try {
      final ProcessResult result = await _processManager.run(<String>[javaBinary, '-version']);
      if (result.exitCode == 0) {
        final List<String> versionLines = (result.stderr as String).split('\n');
        javaVersion = versionLines.length >= 2 ? versionLines[1] : versionLines[0];
      }
    } on Exception catch (error) {
      _logger.printTrace(error.toString());
    }
    if (javaVersion == null) {
      // Could not determine the java version.
      return false;
    }
    return true;
  }

  Future<LicensesAccepted> get licensesAccepted async {
    LicensesAccepted? status;

    void handleLine(String line) {
      if (licenseCounts.hasMatch(line)) {
        final Match? match = licenseCounts.firstMatch(line);
        if (match?.group(1) != match?.group(2)) {
          status = LicensesAccepted.some;
        } else {
          status = LicensesAccepted.none;
        }
      } else if (licenseNotAccepted.hasMatch(line)) {
        // The licenseNotAccepted pattern is trying to match the same line as
        // licenseCounts, but is more general. In case the format changes, a
        // more general match may keep doctor mostly working.
        status = LicensesAccepted.none;
      } else if (licenseAccepted.hasMatch(line)) {
        status ??= LicensesAccepted.all;
      }
    }

    if (!_canRunSdkManager()) {
      return LicensesAccepted.unknown;
    }

    try {
      final Process process = await _processManager.start(
        <String>[_androidSdk!.sdkManagerPath!, '--licenses'],
        environment: _androidSdk!.sdkManagerEnv,
      );
      process.stdin.write('n\n');
      // We expect logcat streams to occasionally contain invalid utf-8,
      // see: https://github.com/flutter/flutter/pull/8864.
      final Future<void> output = process.stdout
        .transform<String>(const Utf8Decoder(reportErrors: false))
        .transform<String>(const LineSplitter())
        .listen(handleLine)
        .asFuture<void>();
      final Future<void> errors = process.stderr
        .transform<String>(const Utf8Decoder(reportErrors: false))
        .transform<String>(const LineSplitter())
        .listen(handleLine)
        .asFuture<void>();
      await Future.wait<void>(<Future<void>>[output, errors]);
      return status ?? LicensesAccepted.unknown;
    } on ProcessException catch (e) {
      _logger.printTrace('Failed to run Android sdk manager: $e');
      return LicensesAccepted.unknown;
    }
  }

  /// Run the Android SDK manager tool in order to accept SDK licenses.
  Future<bool> runLicenseManager() async {
    if (_androidSdk == null) {
      _logger.printStatus(_userMessages.androidSdkShort);
      return false;
    }

    if (!_canRunSdkManager()) {
      throwToolExit(
        'Android sdkmanager not found. Update to the latest Android SDK and ensure that '
        'the cmdline-tools are installed to resolve this.'
      );
    }

    try {
      final Process process = await _processManager.start(
        <String>[_androidSdk!.sdkManagerPath!, '--licenses'],
        environment: _androidSdk!.sdkManagerEnv,
      );

      // The real stdin will never finish streaming. Pipe until the child process
      // finishes.
      unawaited(process.stdin.addStream(_stdio.stdin)
        // If the process exits unexpectedly with an error, that will be
        // handled by the caller.
        .catchError((dynamic err, StackTrace stack) {
          _logger.printTrace('Echoing stdin to the licenses subprocess failed:');
          _logger.printTrace('$err\n$stack');
        }
      ));

      // Wait for stdout and stderr to be fully processed, because process.exitCode
      // may complete first.
      try {
        await Future.wait<void>(<Future<void>>[
          _stdio.addStdoutStream(process.stdout),
          _stdio.addStderrStream(process.stderr),
        ]);
      } on Exception catch (err, stack) {
        _logger.printTrace('Echoing stdout or stderr from the license subprocess failed:');
        _logger.printTrace('$err\n$stack');
      }

      final int exitCode = await process.exitCode;
      return exitCode == 0;
    } on ProcessException catch (e) {
      throwToolExit(_userMessages.androidCannotRunSdkManager(
        _androidSdk?.sdkManagerPath ?? '',
        e.toString(),
        _platform,
      ));
    }
  }

  bool _canRunSdkManager() {
    final String? sdkManagerPath = _androidSdk?.sdkManagerPath;
    if (sdkManagerPath == null) {
      return false;
    }
    return _processManager.canRun(sdkManagerPath);
  }
}
