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

import '../base/config.dart';
import '../base/file_system.dart';
import '../base/logger.dart';
import '../base/os.dart';
import '../base/platform.dart';
import '../base/process.dart';
import '../base/version.dart';
import 'android_studio.dart';

const String _javaExecutable = 'java';

/// Represents an installation of Java.
class Java {
  Java({
    required this.javaHome,
    required this.binaryPath,
    required Logger logger,
    required FileSystem fileSystem,
    required OperatingSystemUtils os,
    required Platform platform,
    required ProcessManager processManager,
  }): _logger = logger,
      _fileSystem = fileSystem,
      _os = os,
      _platform = platform,
      _processManager = processManager,
      _processUtils = ProcessUtils(processManager: processManager, logger: logger);

  /// Within the Java ecosystem, this environment variable is typically set
  /// the install location of a Java Runtime Environment (JRE) or Java
  /// Development Kit (JDK).
  ///
  /// Tools that depend on Java and need to find it will often check this
  /// variable. If you are looking to set `JAVA_HOME` when stating a process,
  /// consider using the [environment] instance property instead.
  static String javaHomeEnvironmentVariable = 'JAVA_HOME';

  /// Finds the Java runtime environment that should be used for all java-dependent
  /// operations across the tool.
  ///
  /// This searches for Java in the following places, in order:
  ///
  /// 1. the runtime environment bundled with Android Studio;
  /// 2. the runtime environment found in the JAVA_HOME env variable, if set; or
  /// 3. the java binary found on PATH.
  ///
  /// Returns null if no java binary could be found.
  static Java? find({
    required Config config,
    required AndroidStudio? androidStudio,
    required Logger logger,
    required FileSystem fileSystem,
    required Platform platform,
    required ProcessManager processManager,
  }) {
    final OperatingSystemUtils os = OperatingSystemUtils(
      fileSystem: fileSystem,
      logger: logger,
      platform: platform,
      processManager: processManager
    );
    final String? home = _findJavaHome(
      config: config,
      logger: logger,
      androidStudio: androidStudio,
      platform: platform
    );
    final String? binary = _findJavaBinary(
      logger: logger,
      javaHome: home,
      fileSystem: fileSystem,
      operatingSystemUtils: os,
      platform: platform
    );

    if (binary == null) {
      return null;
    }

    return Java(
      javaHome: home,
      binaryPath: binary,
      logger: logger,
      fileSystem: fileSystem,
      os: os,
      platform: platform,
      processManager: processManager,
    );
  }

  /// The path of the runtime environments' home directory.
  ///
  /// This should only be used for logging and validation purposes.
  /// If you need to set JAVA_HOME when starting a process, consider
  /// using [environment] instead.
  /// If you need to inspect the files of the runtime, considering adding
  /// a new method to this class instead.
  final String? javaHome;

  /// The path of the runtime environments' java binary.
  ///
  /// This should be only used for logging and validation purposes.
  /// If you need to invoke the binary directly, consider adding a new method
  /// to this class instead.
  final String binaryPath;

  final Logger _logger;
  final FileSystem _fileSystem;
  final OperatingSystemUtils _os;
  final Platform _platform;
  final ProcessManager _processManager;
  final ProcessUtils _processUtils;

  /// Returns an environment variable map with
  /// 1. JAVA_HOME set if this object has a known home directory, and
  /// 2. The java binary folder appended onto PATH, if the binary location is known.
  ///
  /// This map should be used as the environment when invoking any Java-dependent
  /// processes, such as Gradle or Android SDK tools (avdmanager, sdkmanager, etc.)
  Map<String, String> get environment {
    return <String, String>{
      if (javaHome != null) javaHomeEnvironmentVariable: javaHome!,
      'PATH': _fileSystem.path.dirname(binaryPath) +
                        _os.pathVarSeparator +
                        _platform.environment['PATH']!,
    };
  }

  /// Returns the version of java in the format \d(.\d)+(.\d)+
  /// Returns null if version could not be determined.
  late final Version? version = (() {
    final RunResult result = _processUtils.runSync(
      <String>[binaryPath, '--version'],
      environment: environment,
    );
    if (result.exitCode != 0) {
      _logger.printTrace('java --version failed: exitCode: ${result.exitCode}'
        ' stdout: ${result.stdout} stderr: ${result.stderr}');
    }
    final String rawVersionOutput = result.stdout;
    final List<String> versionLines = rawVersionOutput.split('\n');
    // Should look something like 'openjdk 19.0.2 2023-01-17'.
    final String longVersionText = versionLines.length >= 2 ? versionLines[1] : versionLines[0];

    // The contents that matter come in the format '11.0.18' or '1.8.0_202'.
    final RegExp jdkVersionRegex = RegExp(r'\d+\.\d+(\.\d+(?:_\d+)?)?');
    final Iterable<RegExpMatch> matches =
        jdkVersionRegex.allMatches(rawVersionOutput);
    if (matches.isEmpty) {
      // Fallback to second string format like "java 21.0.1 2023-09-19 LTS"
      final RegExp secondJdkVersionRegex =
          RegExp(r'java\s+(?<version>\d+(\.\d+)?(\.\d+)?)\s+\d\d\d\d-\d\d-\d\d');
      final RegExpMatch? match = secondJdkVersionRegex.firstMatch(versionLines[0]);
      if (match != null) {
        final Version? parsedVersion = Version.parse(match.namedGroup('version'));
        if (parsedVersion == null) {
          return null;
        }
        return parsedVersion;
      }
      _logger.printWarning(_formatJavaVersionWarning(rawVersionOutput));
      return null;
    }
    final String? version = matches.first.group(0);
    if (version == null || version.split('_').isEmpty) {
      _logger.printWarning(_formatJavaVersionWarning(rawVersionOutput));
      return null;
    }

    // Trim away _d+ from versions 1.8 and below.
    final String versionWithoutBuildInfo = version.split('_').first;

    final Version? parsedVersion = Version.parse(versionWithoutBuildInfo);
    if (parsedVersion == null) {
      return null;
    }
    return Version.withText(
      parsedVersion.major,
      parsedVersion.minor,
      parsedVersion.patch,
      longVersionText,
    );
  })();

  bool canRun() {
    return _processManager.canRun(binaryPath);
  }
}

String? _findJavaHome({
  required Config config,
  required Logger logger,
  required AndroidStudio? androidStudio,
  required Platform platform,
}) {
  final Object? configured = config.getValue('jdk-dir');
  if (configured != null) {
    return configured as String;
  }

  final String? androidStudioJavaPath = androidStudio?.javaPath;
  if (androidStudioJavaPath != null) {
    return androidStudioJavaPath;
  }

  final String? javaHomeEnv = platform.environment[Java.javaHomeEnvironmentVariable];
  if (javaHomeEnv != null) {
    return javaHomeEnv;
  }
  return null;
}

String? _findJavaBinary({
  required Logger logger,
  required String? javaHome,
  required FileSystem fileSystem,
  required OperatingSystemUtils operatingSystemUtils,
  required Platform platform,
}) {
  if (javaHome != null) {
    return fileSystem.path.join(javaHome, 'bin', 'java');
  }

  // Fallback to PATH based lookup.
  return operatingSystemUtils.which(_javaExecutable)?.path;
}

// Returns a user visible String that says the tool failed to parse
// the version of java along with the output.
String _formatJavaVersionWarning(String javaVersionRaw) {
  return 'Could not parse java version from: \n'
    '$javaVersionRaw \n'
    'If there is a version please look for an existing bug '
    'https://github.com/flutter/flutter/issues/ '
    'and if one does not exist file a new issue.';
}